diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2020-01-30 04:37:25 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-01-30 04:37:25 +0900 |
| commit | f6154dc0af1a0d65819e87240f4385f9573095cb (patch) | |
| tree | 699a5ca07d6727b7f8497d4769f25d6d62f94b5a /src/client | |
| parent | Add Event activity-type support (#5785) (diff) | |
| download | sharkey-f6154dc0af1a0d65819e87240f4385f9573095cb.tar.gz sharkey-f6154dc0af1a0d65819e87240f4385f9573095cb.tar.bz2 sharkey-f6154dc0af1a0d65819e87240f4385f9573095cb.zip | |
v12 (#5712)
Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com>
Co-authored-by: Satsuki Yanagi <17376330+u1-liquid@users.noreply.github.com>
Diffstat (limited to 'src/client')
747 files changed, 22884 insertions, 49687 deletions
diff --git a/src/client/app.vue b/src/client/app.vue new file mode 100644 index 0000000000..3e65880b0a --- /dev/null +++ b/src/client/app.vue @@ -0,0 +1,1105 @@ +<template> +<div class="mk-app" v-hotkey.global="keymap"> + <header class="header"> + <div class="title" ref="title"> + <transition name="header" mode="out-in" appear> + <button class="_button back" v-if="canBack" @click="back()"><fa :icon="faChevronLeft"/></button> + </transition> + <transition name="header" mode="out-in" appear> + <div class="body" :key="pageKey"> + <div class="default"> + <portal-target name="avatar" slim/> + <h1 class="title"><portal-target name="icon" slim/><portal-target name="title" slim/></h1> + </div> + <div class="custom"> + <portal-target name="header" slim/> + </div> + </div> + </transition> + </div> + <div class="sub"> + <fa :icon="faSearch"/> + <input type="search" class="search" :placeholder="$t('search')" v-model="searchQuery" v-autocomplete="{ model: 'searchQuery' }" :disabled="searchWait" @keypress="searchKeypress"/> + <button v-if="$store.getters.isSignedIn" class="post _buttonPrimary" @click="post()"><fa :icon="faPencilAlt"/></button> + </div> + </header> + + <nav class="nav" ref="nav"> + <div> + <button class="item _button account" @click="openAccountMenu" v-if="$store.getters.isSignedIn"> + <mk-avatar :user="$store.state.i" class="avatar"/><mk-acct class="text" :user="$store.state.i"/> + </button> + <router-link class="item" active-class="active" to="/" exact v-if="$store.getters.isSignedIn"> + <fa :icon="faHome" fixed-width/><span class="text">{{ $t('timeline') }}</span> + </router-link> + <router-link class="item" active-class="active" to="/" exact v-else> + <fa :icon="faHome" fixed-width/><span class="text">{{ $t('home') }}</span> + </router-link> + <router-link class="item" active-class="active" to="/featured"> + <fa :icon="faFireAlt" fixed-width/><span class="text">{{ $t('featured') }}</span> + </router-link> + <router-link class="item" active-class="active" to="/explore"> + <fa :icon="faHashtag" fixed-width/><span class="text">{{ $t('explore') }}</span> + </router-link> + <button class="item _button" @click="notificationsOpen = !notificationsOpen" ref="notificationButton" v-if="$store.getters.isSignedIn"> + <fa :icon="faBell" fixed-width/><span class="text">{{ $t('notifications') }}</span> + <i v-if="$store.state.i.hasUnreadNotification"><fa :icon="faCircle"/></i> + </button> + <router-link class="item" active-class="active" to="/my/messaging" v-if="$store.getters.isSignedIn"> + <fa :icon="faComments" fixed-width/><span class="text">{{ $t('messaging') }}</span> + <i v-if="$store.state.i.hasUnreadMessagingMessage"><fa :icon="faCircle"/></i> + </router-link> + <router-link class="item" active-class="active" to="/my/follow-requests" v-if="$store.getters.isSignedIn && $store.state.i.isLocked"> + <fa :icon="faUserClock" fixed-width/><span class="text">{{ $t('followRequests') }}</span> + <i v-if="$store.state.i.pendingReceivedFollowRequestsCount"><fa :icon="faCircle"/></i> + </router-link> + <router-link class="item" active-class="active" to="/my/drive" v-if="$store.getters.isSignedIn"> + <fa :icon="faCloud" fixed-width/><span class="text">{{ $t('drive') }}</span> + </router-link> + <router-link class="item" active-class="active" to="/announcements"> + <fa :icon="faBroadcastTower" fixed-width/><span class="text">{{ $t('announcements') }}</span> + <i v-if="$store.getters.isSignedIn && $store.state.i.hasUnreadAnnouncement"><fa :icon="faCircle"/></i> + </router-link> + <button class="item _button" :class="{ active: $route.path === '/instance' || $route.path.startsWith('/instance/') }" v-if="$store.getters.isSignedIn && ($store.state.i.isAdmin || $store.state.i.isModerator)" @click="oepnInstanceMenu"> + <fa :icon="faServer" fixed-width/><span class="text">{{ $t('instance') }}</span> + </button> + <button class="item _button" @click="search()"> + <fa :icon="faSearch" fixed-width/><span class="text">{{ $t('search') }}</span> + </button> + <button class="item _button" @click="more"> + <fa :icon="faEllipsisH" fixed-width/><span class="text">{{ $t('more') }}</span> + <i v-if="$store.getters.isSignedIn && ($store.state.i.hasUnreadMentions || $store.state.i.hasUnreadSpecifiedNotes)"><fa :icon="faCircle"/></i> + </button> + </div> + </nav> + + <div class="contents"> + <main ref="main"> + <div class="content"> + <transition name="page" mode="out-in"> + <router-view></router-view> + </transition> + </div> + <div class="powerd-by" :class="{ visible: !$store.getters.isSignedIn }"> + <b><router-link to="/">{{ host }}</router-link></b> + <small>Powered by <a href="https://github.com/syuilo/misskey" target="_blank">Misskey</a></small> + </div> + </main> + + <div class="widgets"> + <div ref="widgets" :class="{ edit: widgetsEditMode }"> + <template v-if="enableWidgets && $store.getters.isSignedIn"> + <template v-if="widgetsEditMode"> + <mk-button primary @click="addWidget" class="add"><fa :icon="faPlus"/></mk-button> + <x-draggable + :list="widgets" + handle=".handle" + animation="150" + class="sortable" + @sort="onWidgetSort" + > + <div v-for="widget in widgets" class="customize-container" :key="widget.id"> + <header> + <span class="handle"><fa :icon="faBars"/></span>{{ widget.name }}<button class="remove" @click="removeWidget(widget)"><fa :icon="faTimes"/></button> + </header> + <div @click="widgetFunc(widget.id)"> + <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true"/> + </div> + </div> + </x-draggable> + </template> + <template v-else> + <component class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget"/> + </template> + <button ref="widgetsEditButton" v-if="widgetsEditMode" class="_button edit" @click="widgetsEditMode = false">{{ $t('exitEdit') }}</button> + <button ref="widgetsEditButton" v-else class="_button edit" @click="widgetsEditMode = true">{{ $t('editWidgets') }}</button> + </template> + </div> + </div> + </div> + + <div class="buttons"> + <button v-if="$store.getters.isSignedIn" class="button nav _button" @click="showNav" ref="navButton"><fa :icon="faBars"/><i v-if="$store.state.i.hasUnreadSpecifiedNotes || $store.state.i.pendingReceivedFollowRequestsCount || $store.state.i.hasUnreadMessagingMessage || $store.state.i.hasUnreadAnnouncement"><fa :icon="faCircle"/></i></button> + <button v-if="$store.getters.isSignedIn" class="button home _button" :disabled="$route.path === '/'" @click="$router.push('/')"><fa :icon="faHome"/></button> + <button v-if="$store.getters.isSignedIn" class="button notifications _button" @click="notificationsOpen = !notificationsOpen" ref="notificationButton2"><fa :icon="notificationsOpen ? faTimes : faBell"/><i v-if="$store.state.i.hasUnreadNotification"><fa :icon="faCircle"/></i></button> + <button v-if="$store.getters.isSignedIn" class="button post _buttonPrimary" @click="post()"><fa :icon="faPencilAlt"/></button> + </div> + + <button v-if="$store.getters.isSignedIn" class="post _buttonPrimary" @click="post()"><fa :icon="faPencilAlt"/></button> + + <transition name="zoom-in-top"> + <x-notifications v-if="notificationsOpen" class="notifications" ref="notifications"/> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faChevronLeft, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faListUl, faPlus, faUserClock, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faGamepad, faServer, faFileAlt, faSatellite, faInfoCircle } from '@fortawesome/free-solid-svg-icons'; +import { faBell, faEnvelope, faLaugh, faComments } from '@fortawesome/free-regular-svg-icons'; +import { v4 as uuid } from 'uuid'; +import i18n from './i18n'; +import { host } from './config'; +import { search } from './scripts/search'; +import contains from './scripts/contains'; +import MkToast from './components/toast.vue'; + +export default Vue.extend({ + i18n, + + components: { + XNotifications: () => import('./components/notifications.vue').then(m => m.default), + MkButton: () => import('./components/ui/button.vue').then(m => m.default), + XDraggable: () => import('vuedraggable'), + }, + + data() { + return { + host: host, + pageKey: 0, + searching: false, + notificationsOpen: false, + accounts: [], + lists: [], + connection: null, + searchQuery: '', + searchWait: false, + widgetsEditMode: false, + enableWidgets: window.innerWidth >= 1100, + canBack: false, + faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer + }; + }, + + computed: { + keymap(): any { + return { + 'p': this.post, + 'n': this.post, + }; + }, + + widgets(): any[] { + return this.$store.state.settings.widgets; + } + }, + + watch:{ + $route(to, from) { + this.pageKey++; + this.notificationsOpen = false; + this.canBack = (window.history.length > 0 && !['index'].includes(to.name)); + }, + + notificationsOpen(open) { + if (open) { + for (const el of Array.from(document.querySelectorAll('*'))) { + el.addEventListener('mousedown', this.onMousedown); + } + } else { + for (const el of Array.from(document.querySelectorAll('*'))) { + el.removeEventListener('mousedown', this.onMousedown); + } + } + } + }, + + created() { + if (this.$store.getters.isSignedIn) { + this.connection = this.$root.stream.useSharedConnection('main'); + this.connection.on('notification', this.onNotification); + + if (this.widgets.length === 0) { + this.$store.dispatch('settings/setWidgets', [{ + name: 'notifications', + id: 'a', data: {} + }]); + } + } + + this.$root.stream.on('_disconnected_', async () => { + const confirm = await this.$root.dialog({ + type: 'warning', + showCancelButton: true, + title: this.$t('disconnectedFromServer'), + text: this.$t('reloadConfirm'), + }); + if (!confirm.canceled) { + location.reload(); + } + }); + + setInterval(() => { + this.$refs.title.style.left = (this.$refs.main.getBoundingClientRect().left - this.$refs.nav.offsetWidth) + 'px'; + }, 1000); + + // https://stackoverflow.com/questions/33891709/when-flexbox-items-wrap-in-column-mode-container-does-not-grow-its-width + if (this.enableWidgets) { + setInterval(() => { + const width = this.$refs.widgetsEditButton.offsetLeft + 300; + this.$refs.widgets.style.width = width + 'px'; + }, 1000); + } + }, + + methods: { + back() { + window.history.back(); + }, + + post() { + this.$root.post(); + }, + + search() { + if (this.searching) return; + + this.$root.dialog({ + title: this.$t('search'), + input: true + }).then(async ({ canceled, result: query }) => { + if (canceled || query == null || query == '') return; + + this.searching = true; + search(this, query).finally(() => { + this.searching = false; + }); + }); + }, + + searchKeypress(e) { + if (e.keyCode == 13) { + this.searchWait = true; + search(this, this.searchQuery).finally(() => { + this.searchWait = false; + this.searchQuery = ''; + }); + } + }, + + showNav(ev) { + this.$root.menu({ + items: [{ + text: this.$t('search'), + icon: faSearch, + action: this.search, + }, null, this.$store.state.i.isAdmin || this.$store.state.i.isModerator ? { + text: this.$t('instance'), + icon: faServer, + action: () => this.oepnInstanceMenu(ev), + } : undefined, { + type: 'link', + text: this.$t('announcements'), + to: '/announcements', + icon: faBroadcastTower, + indicate: this.$store.state.i.hasUnreadAnnouncement, + }, { + type: 'link', + text: this.$t('featured'), + to: '/featured', + icon: faFireAlt, + }, { + type: 'link', + text: this.$t('explore'), + to: '/explore', + icon: faHashtag, + }, { + type: 'link', + text: this.$t('messaging'), + to: '/my/messaging', + icon: faComments, + indicate: this.$store.state.i.hasUnreadMessagingMessage, + }, this.$store.state.i.isLocked ? { + type: 'link', + text: this.$t('followRequests'), + to: '/my/follow-requests', + icon: faUserClock, + indicate: this.$store.state.i.pendingReceivedFollowRequestsCount > 0, + } : undefined, { + type: 'link', + text: this.$t('drive'), + to: '/my/drive', + icon: faCloud, + }, { + text: this.$t('more'), + icon: faEllipsisH, + action: () => this.more(ev), + indicate: this.$store.state.i.hasUnreadMentions || this.$store.state.i.hasUnreadSpecifiedNotes + }, null, { + type: 'user', + user: this.$store.state.i, + action: () => this.openAccountMenu(ev), + }], + direction: 'up', + align: 'left', + fixed: true, + width: 200, + source: ev.currentTarget || ev.target, + }); + }, + + async openAccountMenu(ev) { + const accounts = (await this.$root.api('users/show', { userIds: this.$store.state.device.accounts.map(x => x.id) })).filter(x => x.id !== this.$store.state.i.id); + + const accountItems = accounts.map(account => ({ + type: 'user', + user: account, + action: () => { this.switchAccount(account) } + })); + + this.$root.menu({ + items: [...[{ + type: 'link', + text: this.$t('profile'), + to: `/@${ this.$store.state.i.username }`, + avatar: this.$store.state.i, + }, { + type: 'link', + text: this.$t('settings'), + to: '/my/settings', + icon: faCog, + }, null, { + type: 'item', + text: this.$t('addAcount'), + icon: faPlus, + action: () => { this.addAcount() }, + }], ...accountItems], + align: 'left', + fixed: true, + width: 240, + source: ev.currentTarget || ev.target, + }); + }, + + oepnInstanceMenu(ev) { + this.$root.menu({ + items: [{ + type: 'link', + text: this.$t('statistics'), + to: '/instance/stats', + icon: faChartBar, + }, { + type: 'link', + text: this.$t('customEmojis'), + to: '/instance/emojis', + icon: faLaugh, + }, { + type: 'link', + text: this.$t('users'), + to: '/instance/users', + icon: faUsers, + }, { + type: 'link', + text: this.$t('files'), + to: '/instance/files', + icon: faCloud, + }, { + type: 'link', + text: this.$t('monitor'), + to: '/instance/monitor', + icon: faTachometerAlt, + }, { + type: 'link', + text: this.$t('jobQueue'), + to: '/instance/queue', + icon: faExchangeAlt, + }, { + type: 'link', + text: this.$t('federation'), + to: '/instance/federation', + icon: faGlobe, + }, { + type: 'link', + text: this.$t('announcements'), + to: '/instance/announcements', + icon: faBroadcastTower, + }, null, { + type: 'link', + text: this.$t('general'), + to: '/instance', + icon: faCog, + }], + align: 'left', + fixed: true, + width: 200, + source: ev.currentTarget || ev.target, + }); + }, + + more(ev) { + this.$root.menu({ + items: [...(this.$store.getters.isSignedIn ? [{ + type: 'link', + text: this.$t('lists'), + to: '/my/lists', + icon: faListUl, + }, { + type: 'link', + text: this.$t('antennas'), + to: '/my/antennas', + icon: faSatellite, + }, { + type: 'link', + text: this.$t('mentions'), + to: '/my/mentions', + icon: faAt, + indicate: this.$store.state.i.hasUnreadMentions + }, { + type: 'link', + text: this.$t('directNotes'), + to: '/my/messages', + icon: faEnvelope, + indicate: this.$store.state.i.hasUnreadSpecifiedNotes + }, { + type: 'link', + text: this.$t('favorites'), + to: '/my/favorites', + icon: faStar, + }, { + type: 'link', + text: this.$t('pages'), + to: '/my/pages', + icon: faFileAlt, + }, { + type: 'link', + text: this.$t('games'), + to: '/games', + icon: faGamepad, + }, null] : []), { + type: 'link', + text: this.$t('about'), + to: '/about', + icon: faInfoCircle, + }], + align: 'left', + fixed: true, + width: 200, + source: ev.currentTarget || ev.target, + }); + }, + + async addAcount() { + this.$root.new(await import('./components/signin-dialog.vue').then(m => m.default)).$once('login', res => { + this.$store.dispatch('addAcount', res); + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }); + }, + + async switchAccount(account) { + const token = this.$store.state.device.accounts.find(x => x.id === account.id).token; + this.$root.api('i', {}, token).then(i => { + this.$store.dispatch('switchAccount', { + ...i, + token: token + }); + location.reload(); + }); + }, + + onNotification(notification) { + // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない + this.$root.stream.send('readNotification', { + id: notification.id + }); + + this.$root.new(MkToast, { + notification + }); + }, + + onMousedown(e) { + e.preventDefault(); + if (!contains(this.$refs.notifications.$el, e.target) && + !contains(this.$refs.notificationButton, e.target) && + !contains(this.$refs.notificationButton2, e.target) + ) this.notificationsOpen = false; + return false; + }, + + widgetFunc(id) { + const w = this.$refs[id][0]; + if (w.func) w.func(); + }, + + onWidgetSort() { + this.saveHome(); + }, + + addWidget(ev) { + const widgets = [ + 'memo', + 'notifications', + 'timeline', + 'calendar', + 'rss', + 'trends', + ]; + + this.$root.menu({ + items: widgets.map(widget => ({ + text: this.$t('_widgets.' + widget), + action: () => { + this.$store.dispatch('settings/addWidget', { + name: widget, + id: uuid(), + data: {} + }); + } + })), + source: ev.currentTarget || ev.target, + }); + }, + + removeWidget(widget) { + this.$store.dispatch('settings/removeWidget', widget); + }, + + saveHome() { + this.$store.dispatch('settings/setWidgets', this.widgets); + } + } +}); +</script> + +<style lang="scss" scoped> +@keyframes blink { + 0% { opacity: 1; } + 30% { opacity: 1; } + 90% { opacity: 0; } +} + +.header-enter-active, .header-leave-active { + transition: opacity 0.5s, transform 0.5s !important; +} +.header-enter { + opacity: 0; + transform: scale(0.9); +} +.header-leave-to { + opacity: 0; + transform: scale(0.9); +} + +.page-enter-active, .page-leave-active { + transition: opacity 0.5s, transform 0.5s !important; +} +.page-enter { + opacity: 0; + transform: translateY(-32px); +} +.page-leave-to { + opacity: 0; + transform: translateY(32px); +} + +.mk-app { + $header-height: 60px; + $nav-width: 250px; + $nav-icon-only-width: 74px; + $main-width: 700px; + $ui-font-size: 1em; + $nav-icon-only-threshold: 1300px; + $nav-hide-threshold: 700px; + $side-hide-threshold: 1100px; + + min-height: 100vh; + box-sizing: border-box; + padding-top: $header-height; + + &, > .header > .body { + display: flex; + margin: 0 auto; + } + + > .header { + position: fixed; + z-index: 1000; + top: 0; + right: 0; + height: $header-height; + width: calc(100% - #{$nav-width}); + //background-color: var(--panel); + -webkit-backdrop-filter: blur(32px); + backdrop-filter: blur(32px); + background-color: var(--header); + border-bottom: solid 1px var(--divider); + + @media (max-width: $nav-icon-only-threshold) { + width: calc(100% - #{$nav-icon-only-width}); + } + + @media (max-width: $nav-hide-threshold) { + width: 100%; + } + + > .title { + position: relative; + line-height: $header-height; + height: $header-height; + max-width: $main-width; + text-align: center; + + > .back { + position: absolute; + z-index: 1; + top: 0; + left: 0; + height: $header-height; + width: $header-height; + } + + > .body { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + height: $header-height; + + > .default { + padding: 0 $header-height; + + > .avatar { + $size: 32px; + display: inline-block; + width: $size; + height: $size; + vertical-align: bottom; + margin: (($header-height - $size) / 2) 8px (($header-height - $size) / 2) 0; + } + + > .title { + display: inline-block; + font-size: $ui-font-size; + margin: 0; + line-height: $header-height; + + > [data-icon] { + margin-right: 8px; + } + } + } + + > .custom { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + } + } + } + + > .sub { + $post-button-size: 42px; + $post-button-margin: (($header-height - $post-button-size) / 2); + position: absolute; + top: 0; + right: 16px; + height: $header-height; + + @media (max-width: $side-hide-threshold) { + display: none; + } + + > [data-icon] { + position: absolute; + top: 0; + left: 16px; + height: $header-height; + pointer-events: none; + font-size: 16px; + } + + > .search { + $margin: 8px; + width: calc(100% - #{$post-button-size + $post-button-margin + $margin}); + box-sizing: border-box; + margin-right: $margin; + padding: 0 12px 0 42px; + font-size: 1rem; + line-height: 38px; + border: none; + border-radius: 38px; + color: var(--fg); + background: var(--bg); + + &:focus { + outline: none; + } + } + + > .post { + width: $post-button-size; + height: $post-button-size; + margin: $post-button-margin 0 $post-button-margin $post-button-margin; + border-radius: 100%; + font-size: 16px; + } + } + } + + > .nav { + $avatar-size: 32px; + $avatar-margin: ($header-height - $avatar-size) / 2; + + flex: 0 0 $nav-width; + width: $nav-width; + box-sizing: border-box; + + @media (max-width: $nav-icon-only-threshold) { + flex: 0 0 $nav-icon-only-width; + width: $nav-icon-only-width; + } + + @media (max-width: $nav-hide-threshold) { + display: none; + } + + > div { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + width: $nav-width; + height: 100vh; + padding-top: 16px; + box-sizing: border-box; + background: var(--navBg); + border-right: solid 1px var(--divider); + + @media (max-width: $nav-icon-only-threshold) { + width: $nav-icon-only-width; + } + + > .item { + position: relative; + display: block; + padding-left: 32px; + font-size: $ui-font-size; + font-weight: bold; + line-height: 3.2rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + color: var(--navFg); + + &:not(.active) { + opacity: 0.85; + + &:hover { + opacity: 1; + + > [data-icon] { + opacity: 1; + } + } + + > [data-icon] { + opacity: 0.85; + } + } + + > [data-icon] { + width: ($header-height - ($avatar-margin * 2)); + } + + > [data-icon], + > .avatar { + margin-right: $avatar-margin; + } + + > .avatar { + width: $avatar-size; + height: $avatar-size; + vertical-align: middle; + } + + > i { + position: absolute; + top: 0; + left: 20px; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } + + &:hover { + text-decoration: none; + } + + &.active { + color: var(--navActive); + } + + @media (max-width: $nav-icon-only-threshold) { + padding-left: 0; + width: 100%; + text-align: center; + font-size: $ui-font-size * 1.2; + line-height: 3.5rem; + + > [data-icon], + > .avatar { + margin-right: 0; + } + + > i { + left: 10px; + } + + > .text { + display: none; + } + } + } + } + } + + > .contents { + display: flex; + margin: 0 auto; + min-width: 0; + + > main { + width: $main-width; + min-width: $main-width; + + @media (max-width: $side-hide-threshold) { + min-width: 0; + } + + > .content { + padding: 16px; + box-sizing: border-box; + + @media (max-width: 500px) { + padding: 8px; + } + } + + > .powerd-by { + font-size: 14px; + text-align: center; + margin: 32px 0; + visibility: hidden; + + &.visible { + visibility: visible; + } + + &:not(.visible) { + @media (min-width: 850px) { + display: none; + } + } + + @media (max-width: 500px) { + margin-top: 16px; + } + + > small { + display: block; + margin-top: 8px; + opacity: 0.5; + + @media (max-width: 500px) { + margin-top: 4px; + } + } + } + } + + > .widgets { + box-sizing: border-box; + + @media (max-width: $side-hide-threshold) { + display: none; + } + + > div { + position: sticky; + top: calc(#{$header-height} + var(--margin)); + height: calc(100vh - #{$header-height} - var(--margin)); + + &.edit { + overflow: auto; + width: auto !important; + } + + &:not(.edit) { + display: inline-flex; + flex-wrap: wrap; + flex-direction: column; + place-content: flex-start; + } + + > * { + margin: 0 var(--margin) var(--margin) 0; + width: 300px; + } + + > .add { + margin: 0 auto; + } + + > .edit { + display: block; + font-size: 0.9em; + margin: 0 auto; + } + + .customize-container { + margin: 8px 0; + background: #fff; + + > header { + position: relative; + line-height: 32px; + background: #eee; + + > .handle { + padding: 0 8px; + } + + > .remove { + position: absolute; + top: 0; + right: 0; + padding: 0 8px; + line-height: 32px; + } + } + + > div { + padding: 8px; + + > * { + pointer-events: none; + } + } + } + } + } + } + + > .post { + display: none; + position: fixed; + z-index: 1000; + bottom: 32px; + right: 32px; + width: 64px; + height: 64px; + border-radius: 100%; + box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12); + font-size: 22px; + + @media (min-width: ($nav-hide-threshold + 1px)) { + display: block; + } + + @media (min-width: ($side-hide-threshold + 1px)) { + display: none; + } + } + + > .buttons { + position: fixed; + z-index: 1000; + bottom: 0; + padding: 0 32px 32px 32px; + display: flex; + width: 100%; + box-sizing: border-box; + background: linear-gradient(0deg, var(--bg), var(--bonzsgfz)); + + @media (max-width: 500px) { + padding: 0 16px 16px 16px; + } + + @media (min-width: ($nav-hide-threshold + 1px)) { + display: none; + } + + > .button { + position: relative; + padding: 0; + margin: auto; + width: 64px; + height: 64px; + border-radius: 100%; + box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12); + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } + + > * { + font-size: 22px; + } + + &:disabled { + cursor: default; + + > * { + opacity: 0.5; + } + } + + &:not(.post) { + background: var(--panel); + color: var(--fg); + + &:hover { + background: var(--pcncwizz); + } + + > i { + position: absolute; + top: 0; + left: 0; + color: var(--accent); + font-size: 16px; + animation: blink 1s infinite; + } + } + } + } + + > .notifications { + position: fixed; + top: 32px; + left: 0; + right: 0; + margin: 0 auto; + z-index: 10001; + width: 350px; + height: 400px; + background: var(--vocsgcxy); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + border-radius: 6px; + box-shadow: 0 3px 12px rgba(27, 31, 35, 0.15); + overflow: hidden; + + @media (max-width: 800px) { + width: 320px; + height: 350px; + } + + @media (max-width: 500px) { + width: 290px; + height: 310px; + } + } +} +</style> diff --git a/src/client/app/admin/assets/header-icon.svg b/src/client/app/admin/assets/header-icon.svg deleted file mode 100644 index d677d2d163..0000000000 --- a/src/client/app/admin/assets/header-icon.svg +++ /dev/null @@ -1,150 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<!-- Created with Inkscape (http://www.inkscape.org/) --> - -<svg - xmlns:dc="http://purl.org/dc/elements/1.1/" - xmlns:cc="http://creativecommons.org/ns#" - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" - xmlns:svg="http://www.w3.org/2000/svg" - xmlns="http://www.w3.org/2000/svg" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="512" - height="512" - viewBox="0 0 135.46667 135.46667" - version="1.1" - id="svg8" - inkscape:version="0.92.1 r15371" - sodipodi:docname="header-icon.dark.svg" - inkscape:export-filename="C:\Users\syuilo\projects\misskey\assets\favicon\32.png" - inkscape:export-xdpi="6" - inkscape:export-ydpi="6"> - <defs - id="defs2"> - <inkscape:path-effect - effect="simplify" - id="path-effect5115" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - <inkscape:path-effect - effect="simplify" - id="path-effect5111" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - <inkscape:path-effect - effect="simplify" - id="path-effect5104" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - </defs> - <sodipodi:namedview - id="base" - pagecolor="#ffffff" - bordercolor="#666666" - borderopacity="1.0" - inkscape:pageopacity="0.0" - inkscape:pageshadow="2" - inkscape:zoom="1.4142136" - inkscape:cx="114.309" - inkscape:cy="251.50613" - inkscape:document-units="px" - inkscape:current-layer="g4502" - showgrid="true" - units="px" - inkscape:snap-bbox="true" - inkscape:bbox-nodes="true" - inkscape:snap-bbox-edge-midpoints="false" - inkscape:snap-smooth-nodes="true" - inkscape:snap-center="true" - inkscape:snap-page="true" - inkscape:window-width="1920" - inkscape:window-height="1027" - inkscape:window-x="-8" - inkscape:window-y="1072" - inkscape:window-maximized="1" - inkscape:snap-object-midpoints="true" - inkscape:snap-midpoints="true" - inkscape:object-paths="true" - fit-margin-top="0" - fit-margin-left="0" - fit-margin-right="0" - fit-margin-bottom="0" - objecttolerance="1" - guidetolerance="1" - inkscape:snap-nodes="false" - inkscape:snap-others="false"> - <inkscape:grid - type="xygrid" - id="grid4504" - spacingx="4.2333334" - spacingy="4.2333334" - empcolor="#ff3fff" - empopacity="0.25098039" - empspacing="4" /> - </sodipodi:namedview> - <metadata - id="metadata5"> - <rdf:RDF> - <cc:Work - rdf:about=""> - <dc:format>image/svg+xml</dc:format> - <dc:type - rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> - <dc:title></dc:title> - </cc:Work> - </rdf:RDF> - </metadata> - <g - inkscape:label="レイヤー 1" - inkscape:groupmode="layer" - id="layer1" - transform="translate(-30.809093,-111.78601)"> - <g - id="g4502" - transform="matrix(1.096096,0,0,1.096096,-2.960633,-44.023579)"> - <g - style="fill-opacity:1" - transform="translate(-1.3333333e-6,-1.3439941e-6)" - id="g5125"> - <g - transform="matrix(0.91391326,0,0,0.91391326,7.9719907,17.595761)" - id="text4489" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.28950602px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - aria-label="Mi"> - <path - sodipodi:nodetypes="zccssscssccscczzzccsccsscscsccz" - inkscape:connector-curvature="0" - id="path5210" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill-opacity:1;stroke-width:0.28950602px" - d="m 75.196381,231.17126 c -5.855419,0.0202 -10.885068,-3.50766 -13.2572,-7.61584 -1.266603,-1.79454 -3.772419,-2.43291 -3.807919,0 v 11.2332 c 0,4.51309 -1.645397,8.41504 -4.936191,11.70583 -3.196772,3.19677 -7.098714,4.79516 -11.705826,4.79516 -4.513089,0 -8.415031,-1.59839 -11.705825,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -61.7729 c 0,-3.47884 0.987238,-6.6286 2.961715,-9.44928 2.068499,-2.91471 4.701135,-4.9362 7.897906,-6.06447 1.786431,-0.65816 3.666885,-0.98724 5.641362,-0.98724 5.077225,0 9.308247,1.97448 12.693064,5.92343 1.786431,1.97448 2.820681,3.00873 3.102749,3.10275 0,0 13.408119,16.21319 13.78421,16.49526 0.376091,0.28206 1.480789,2.43848 4.127113,2.43848 2.646324,0 3.89218,-2.15642 4.26827,-2.43848 0.376091,-0.28207 13.784088,-16.49526 13.784088,-16.49526 0.09402,0.094 1.081261,-0.94022 2.961715,-3.10275 3.478837,-3.94895 7.756866,-5.92343 12.834096,-5.92343 1.88045,0 3.76091,0.32908 5.64136,0.98724 3.19677,1.12827 5.7824,3.14976 7.75688,6.06447 2.06849,2.82068 3.10274,5.97044 3.10274,9.44928 v 61.7729 c 0,4.51309 -1.6454,8.41504 -4.93619,11.70583 -3.19677,3.19677 -7.09871,4.79516 -11.70582,4.79516 -4.51309,0 -8.41504,-1.59839 -11.705828,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -11.2332 c -0.277898,-3.06563 -2.987588,-1.13379 -3.948953,0 -2.538613,4.70114 -7.401781,7.59567 -13.2572,7.61584 z" /> - <path - inkscape:connector-curvature="0" - id="path5212" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill-opacity:1;stroke-width:0.28950602px" - d="m 145.83461,185.00361 q -5.92343,0 -10.15445,-4.08999 -4.08999,-4.23102 -4.08999,-10.15445 0,-5.92343 4.08999,-10.01342 4.23102,-4.23102 10.15445,-4.23102 5.92343,0 10.15445,4.23102 4.23102,4.08999 4.23102,10.01342 0,5.92343 -4.23102,10.15445 -4.23102,4.08999 -10.15445,4.08999 z m 0.14103,2.82068 q 5.92343,0 10.01342,4.23102 4.23102,4.23102 4.23102,10.15445 v 34.83541 q 0,5.92343 -4.23102,10.15445 -4.08999,4.08999 -10.01342,4.08999 -5.92343,0 -10.15445,-4.08999 -4.23102,-4.23102 -4.23102,-10.15445 v -34.83541 q 0,-5.92343 4.23102,-10.15445 4.23102,-4.23102 10.15445,-4.23102 z" /> - </g> - </g> - </g> - </g> -</svg> diff --git a/src/client/app/admin/script.ts b/src/client/app/admin/script.ts deleted file mode 100644 index 3f2d6466ac..0000000000 --- a/src/client/app/admin/script.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Admin - */ - -import VueRouter from 'vue-router'; - -// Style -import './style.styl'; - -import init from '../init'; -import Index from './views/index.vue'; -import NotFound from '../common/views/pages/not-found.vue'; - -init(launch => { - document.title = 'Admin'; - - // Init router - const router = new VueRouter({ - mode: 'history', - base: '/admin/', - routes: [ - { path: '/:page', component: Index }, - { path: '/', redirect: '/dashboard' }, - { path: '*', component: NotFound } - ] - }); - - // Launch the app - launch(router); -}); diff --git a/src/client/app/admin/style.styl b/src/client/app/admin/style.styl deleted file mode 100644 index ae1a28226a..0000000000 --- a/src/client/app/admin/style.styl +++ /dev/null @@ -1,6 +0,0 @@ -@import "../app" -@import "../reset" - -html - height 100% - background var(--bg) diff --git a/src/client/app/admin/views/abuse.vue b/src/client/app/admin/views/abuse.vue deleted file mode 100644 index afa285debc..0000000000 --- a/src/client/app/admin/views/abuse.vue +++ /dev/null @@ -1,83 +0,0 @@ -<template> -<div> - <ui-card> - <template #title><fa :icon="faExclamationCircle"/> {{ $t('title') }}</template> - <section class="fit-top"> - <sequential-entrance animation="entranceFromTop" delay="25"> - <div v-for="report in userReports" :key="report.id" class="haexwsjc"> - <ui-horizon-group inputs> - <ui-input :value="report.user | acct" type="text" readonly> - <span>{{ $t('target') }}</span> - </ui-input> - <ui-input :value="report.reporter | acct" type="text" readonly> - <span>{{ $t('reporter') }}</span> - </ui-input> - </ui-horizon-group> - <ui-textarea :value="report.comment" readonly> - <span>{{ $t('details') }}</span> - </ui-textarea> - <ui-button @click="removeReport(report)">{{ $t('remove-report') }}</ui-button> - </div> - </sequential-entrance> - <ui-button v-if="existMore" @click="fetchUserReports">{{ $t('@.load-more') }}</ui-button> - </section> - </ui-card> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; - -export default Vue.extend({ - i18n: i18n('admin/views/abuse.vue'), - - data() { - return { - limit: 10, - untilId: undefined, - userReports: [], - existMore: false, - faExclamationCircle - }; - }, - - mounted() { - this.fetchUserReports(); - }, - - methods: { - fetchUserReports() { - this.$root.api('admin/abuse-user-reports', { - untilId: this.untilId, - limit: this.limit + 1 - }).then(reports => { - if (reports.length == this.limit + 1) { - reports.pop(); - this.existMore = true; - } else { - this.existMore = false; - } - this.userReports = this.userReports.concat(reports); - this.untilId = this.userReports[this.userReports.length - 1].id; - }); - }, - - removeReport(report) { - this.$root.api('admin/remove-abuse-user-report', { - reportId: report.id - }).then(() => { - this.userReports = this.userReports.filter(r => r.id != report.id); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.haexwsjc - padding-bottom 16px - border-bottom solid 1px var(--faceDivider) - -</style> diff --git a/src/client/app/admin/views/announcements.vue b/src/client/app/admin/views/announcements.vue deleted file mode 100644 index f6c0540b37..0000000000 --- a/src/client/app/admin/views/announcements.vue +++ /dev/null @@ -1,91 +0,0 @@ -<template> -<div> - <ui-card> - <template #title><fa :icon="faBroadcastTower"/> {{ $t('announcements') }}</template> - <section v-for="(announcement, i) in announcements" class="fit-top"> - <ui-input v-model="announcement.title" @change="save"> - <span>{{ $t('title') }}</span> - </ui-input> - <ui-textarea v-model="announcement.text"> - <span>{{ $t('text') }}</span> - </ui-textarea> - <ui-input v-model="announcement.image"> - <span>{{ $t('image-url') }}</span> - </ui-input> - <ui-horizon-group class="fit-bottom"> - <ui-button @click="save()"><fa :icon="['far', 'save']"/> {{ $t('save') }}</ui-button> - <ui-button @click="remove(i)"><fa :icon="['far', 'trash-alt']"/> {{ $t('remove') }}</ui-button> - </ui-horizon-group> - </section> - <section> - <ui-button @click="add"><fa :icon="faPlus"/> {{ $t('add') }}</ui-button> - </section> - </ui-card> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -import { faBroadcastTower, faPlus } from '@fortawesome/free-solid-svg-icons'; - -export default Vue.extend({ - i18n: i18n('admin/views/announcements.vue'), - data() { - return { - announcements: [], - faBroadcastTower, faPlus - }; - }, - - created() { - this.$root.getMeta().then(meta => { - this.announcements = meta.announcements; - }); - }, - - methods: { - add() { - this.announcements.unshift({ - title: '', - text: '', - image: null - }); - }, - - remove(i) { - this.$root.dialog({ - type: 'warning', - text: this.$t('_remove.are-you-sure').replace('$1', this.announcements.find((_, j) => j == i).title), - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) return; - this.announcements = this.announcements.filter((_, j) => j !== i); - this.save(true); - this.$root.dialog({ - type: 'success', - text: this.$t('_remove.removed') - }); - }); - }, - - save(silent) { - this.$root.api('admin/update-meta', { - announcements: this.announcements - }).then(() => { - if (!silent) { - this.$root.dialog({ - type: 'success', - text: this.$t('saved') - }); - } - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - } - } -}); -</script> diff --git a/src/client/app/admin/views/dashboard.ap-log.vue b/src/client/app/admin/views/dashboard.ap-log.vue deleted file mode 100644 index ee48ef15ea..0000000000 --- a/src/client/app/admin/views/dashboard.ap-log.vue +++ /dev/null @@ -1,109 +0,0 @@ -<template> -<div class="hyhctythnmwihguaaapnbrbszsjqxpio"> - <table> - <thead> - <tr> - <th><fa :icon="faExchangeAlt"/> In/Out</th> - <th><fa :icon="faBolt"/> Activity</th> - <th><fa icon="server"/> Host</th> - <th><fa icon="user"/> Actor</th> - </tr> - </thead> - <tbody> - <tr v-for="log in logs" :key="log.id"> - <td :class="log.direction">{{ log.direction == 'in' ? '<' : '>' }} {{ log.direction }}</td> - <td>{{ log.activity }}</td> - <td>{{ log.host }}</td> - <td>@{{ log.actor }}</td> - </tr> - </tbody> - </table> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faBolt, faExchangeAlt } from '@fortawesome/free-solid-svg-icons'; - -export default Vue.extend({ - data() { - return { - logs: [], - connection: null, - faBolt, faExchangeAlt - }; - }, - - mounted() { - this.connection = this.$root.stream.useSharedConnection('apLog'); - this.connection.on('log', this.onLog); - this.connection.on('logs', this.onLogs); - this.connection.send('requestLog', { - id: Math.random().toString().substr(2, 8), - length: 50 - }); - }, - - beforeDestroy() { - this.connection.dispose(); - }, - - methods: { - onLog(log) { - log.id = Math.random(); - this.logs.unshift(log); - if (this.logs.length > 50) this.logs.pop(); - }, - - onLogs(logs) { - for (const log of logs.reverse()) { - this.onLog(log) - } - } - } -}); -</script> - -<style lang="stylus" scoped> -.hyhctythnmwihguaaapnbrbszsjqxpio - display block - padding 12px 16px 16px 16px - height 250px - overflow auto - box-shadow 0 2px 4px rgba(0, 0, 0, 0.1) - background var(--adminDashboardCardBg) - border-radius 8px - - > table - width 100% - max-width 100% - overflow auto - border-spacing 0 - border-collapse collapse - color var(--adminDashboardCardFg) - font-size 14px - - thead - border-bottom solid 1px var(--adminDashboardCardDivider) - - tr - th - font-weight normal - text-align left - - tbody - tr - &:nth-child(odd) - background rgba(0, 0, 0, 0.025) - - th, td - padding 8px 16px - min-width 128px - - td.in - color #d26755 - - td.out - color #55bb83 - -</style> diff --git a/src/client/app/admin/views/dashboard.cpu-memory.vue b/src/client/app/admin/views/dashboard.cpu-memory.vue deleted file mode 100644 index a3951e7618..0000000000 --- a/src/client/app/admin/views/dashboard.cpu-memory.vue +++ /dev/null @@ -1,185 +0,0 @@ -<template> -<div class="zyknedwtlthezamcjlolyusmipqmjgxz"> - <div> - <header> - <span><fa icon="microchip"/> CPU <span>{{ cpuP }}%</span></span> - <span v-if="meta">{{ meta.cpu.model }}</span> - </header> - <div ref="cpu"></div> - </div> - <div> - <header> - <span><fa icon="memory"/> MEM <span>{{ memP }}%</span></span> - <span v-if="meta"></span> - </header> - <div ref="mem"></div> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import ApexCharts from 'apexcharts'; - -export default Vue.extend({ - props: ['connection'], - - data() { - return { - stats: [], - cpuChart: null, - memChart: null, - cpuP: '', - memP: '', - meta: null - }; - }, - - watch: { - stats(stats) { - this.cpuChart.updateSeries([{ - data: stats.map((x, i) => ({ x: i, y: x.cpu_usage })) - }]); - this.memChart.updateSeries([{ - data: stats.map((x, i) => ({ x: i, y: (x.mem.used / x.mem.total) })) - }]); - } - }, - - mounted() { - this.$root.getMeta().then(meta => { - this.meta = meta; - }); - - this.connection.on('stats', this.onStats); - this.connection.on('statsLog', this.onStatsLog); - this.connection.send('requestLog', { - id: Math.random().toString().substr(2, 8), - length: 200 - }); - - const chartOpts = { - chart: { - type: 'area', - height: 200, - animations: { - dynamicAnimation: { - enabled: false - } - }, - toolbar: { - show: false - }, - zoom: { - enabled: false - } - }, - dataLabels: { - enabled: false - }, - grid: { - clipMarkers: false, - borderColor: 'rgba(0, 0, 0, 0.1)' - }, - stroke: { - curve: 'straight', - width: 2 - }, - tooltip: { - enabled: false - }, - series: [{ - data: [] - }], - xaxis: { - type: 'numeric', - labels: { - show: false - }, - tooltip: { - enabled: false - } - }, - yaxis: { - show: false, - min: 0, - max: 1 - } - }; - - this.cpuChart = new ApexCharts(this.$refs.cpu, chartOpts); - this.memChart = new ApexCharts(this.$refs.mem, chartOpts); - - this.cpuChart.render(); - this.memChart.render(); - }, - - beforeDestroy() { - this.connection.off('stats', this.onStats); - this.connection.off('statsLog', this.onStatsLog); - - this.cpuChart.destroy(); - this.memChart.destroy(); - }, - - methods: { - onStats(stats) { - this.stats.push(stats); - if (this.stats.length > 200) this.stats.shift(); - - this.cpuP = (stats.cpu_usage * 100).toFixed(0); - this.memP = (stats.mem.used / stats.mem.total * 100).toFixed(0); - }, - - onStatsLog(statsLog) { - for (const stats of statsLog.reverse()) { - this.onStats(stats); - } - } - } -}); -</script> - -<style lang="stylus" scoped> -.zyknedwtlthezamcjlolyusmipqmjgxz - display flex - - > div - display block - flex 1 - padding 20px 12px 0 12px - box-shadow 0 2px 4px rgba(0, 0, 0, 0.1) - background var(--face) - border-radius 8px - - &:first-child - margin-right 16px - - > header - display flex - padding 0 8px - margin-bottom -16px - color var(--adminDashboardCardFg) - font-size 14px - - > span - &:last-child - margin-left auto - opacity 0.7 - - > span - opacity 0.7 - - > div - margin-bottom -10px - - @media (max-width 1000px) - display block - margin-bottom 26px - - > div - &:first-child - margin-right 0 - margin-bottom 26px - -</style> diff --git a/src/client/app/admin/views/dashboard.queue-charts.vue b/src/client/app/admin/views/dashboard.queue-charts.vue deleted file mode 100644 index d2d7811bff..0000000000 --- a/src/client/app/admin/views/dashboard.queue-charts.vue +++ /dev/null @@ -1,196 +0,0 @@ -<template> -<div class="mzxlfysy"> - <div> - <header> - <span><fa :icon="faInbox"/> In</span> - <span v-if="latestStats">{{ latestStats.inbox.activeSincePrevTick | number }} / {{ latestStats.inbox.delayed | number }}</span> - </header> - <div ref="in"></div> - </div> - <div> - <header> - <span><fa :icon="faPaperPlane"/> Out</span> - <span v-if="latestStats">{{ latestStats.deliver.activeSincePrevTick | number }} / {{ latestStats.deliver.delayed | number }}</span> - </header> - <div ref="out"></div> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faInbox } from '@fortawesome/free-solid-svg-icons'; -import { faPaperPlane } from '@fortawesome/free-regular-svg-icons'; -import ApexCharts from 'apexcharts'; - -const limit = 150; - -export default Vue.extend({ - data() { - return { - stats: [], - inChart: null, - outChart: null, - faInbox, faPaperPlane - }; - }, - - computed: { - latestStats(): any { - return this.stats[this.stats.length - 1]; - } - }, - - watch: { - stats(stats) { - this.inChart.updateSeries([{ - data: stats.map((x, i) => ({ x: i, y: x.inbox.activeSincePrevTick })) - }, { - data: stats.map((x, i) => ({ x: i, y: x.inbox.active })) - }, { - data: stats.map((x, i) => ({ x: i, y: x.inbox.waiting })) - }, { - data: stats.map((x, i) => ({ x: i, y: x.inbox.delayed })) - }]); - this.outChart.updateSeries([{ - data: stats.map((x, i) => ({ x: i, y: x.deliver.activeSincePrevTick })) - }, { - data: stats.map((x, i) => ({ x: i, y: x.deliver.active })) - }, { - data: stats.map((x, i) => ({ x: i, y: x.deliver.waiting })) - }, { - data: stats.map((x, i) => ({ x: i, y: x.deliver.delayed })) - }]); - } - }, - - mounted() { - const chartOpts = { - chart: { - type: 'area', - height: 200, - animations: { - dynamicAnimation: { - enabled: false - } - }, - toolbar: { - show: false - }, - zoom: { - enabled: false - } - }, - dataLabels: { - enabled: false - }, - grid: { - clipMarkers: false, - borderColor: 'rgba(0, 0, 0, 0.1)' - }, - stroke: { - curve: 'straight', - width: 2 - }, - tooltip: { - enabled: false - }, - legend: { - show: false - }, - colors: ['#00E396', '#00BCD4', '#FFB300', '#e53935'], - series: [{ data: [] }, { data: [] }, { data: [] }, { data: [] }] as any, - xaxis: { - type: 'numeric', - labels: { - show: false - }, - tooltip: { - enabled: false - } - }, - yaxis: { - show: false, - min: 0, - } - }; - - this.inChart = new ApexCharts(this.$refs.in, chartOpts); - this.outChart = new ApexCharts(this.$refs.out, chartOpts); - - this.inChart.render(); - this.outChart.render(); - - const connection = this.$root.stream.useSharedConnection('queueStats'); - connection.on('stats', this.onStats); - connection.on('statsLog', this.onStatsLog); - connection.send('requestLog', { - id: Math.random().toString().substr(2, 8), - length: limit - }); - - this.$once('hook:beforeDestroy', () => { - connection.dispose(); - this.inChart.destroy(); - this.outChart.destroy(); - }); - }, - - methods: { - onStats(stats) { - this.stats.push(stats); - if (this.stats.length > limit) this.stats.shift(); - }, - - onStatsLog(statsLog) { - for (const stats of statsLog.reverse()) { - this.onStats(stats); - } - } - } -}); -</script> - -<style lang="stylus" scoped> -.mzxlfysy - display flex - - > div - display block - flex 1 - padding 20px 12px 0 12px - box-shadow 0 2px 4px rgba(0, 0, 0, 0.1) - background var(--face) - border-radius 8px - - &:first-child - margin-right 16px - - > header - display flex - padding 0 8px - margin-bottom -16px - color var(--adminDashboardCardFg) - font-size 14px - - > span - &:last-child - margin-left auto - opacity 0.7 - - > span - opacity 0.7 - - > div - margin-bottom -10px - - @media (max-width 1000px) - display block - margin-bottom 26px - - > div - &:first-child - margin-right 0 - margin-bottom 26px - -</style> diff --git a/src/client/app/admin/views/dashboard.vue b/src/client/app/admin/views/dashboard.vue deleted file mode 100644 index 5ccfaa06ca..0000000000 --- a/src/client/app/admin/views/dashboard.vue +++ /dev/null @@ -1,286 +0,0 @@ -<template> -<div class="obdskegsannmntldydackcpzezagxqfy"> - <header v-if="meta"> - <p><b>Misskey</b><span>{{ meta.version }}</span></p> - <p><b>Machine</b><span>{{ meta.machine }}</span></p> - <p><b>OS</b><span>{{ meta.os }}</span></p> - <p><b>Node</b><span>{{ meta.node }}</span></p> - <p>{{ $t('@.ai-chan-kawaii') }}</p> - </header> - - <marquee-text v-if="instances.length > 0" class="instances" :repeat="10" :duration="60"> - <span v-for="instance in instances" class="instance"> - <b :style="{ background: instance.bg }">{{ instance.host }}</b>{{ instance.notesCount | number }} / {{ instance.usersCount | number }} - </span> - </marquee-text> - - <div v-if="stats" class="stats"> - <div> - <div> - <div><fa icon="user"/></div> - <div> - <span>{{ $t('accounts') }}</span> - <b>{{ stats.originalUsersCount | number }}</b> - </div> - </div> - <div> - <span><fa icon="home"/> {{ $t('this-instance') }}</span> - <span @click="setChartSrc('users')"><fa :icon="['far', 'chart-bar']"/></span> - </div> - </div> - <div> - <div> - <div><fa icon="pencil-alt"/></div> - <div> - <span>{{ $t('notes') }}</span> - <b>{{ stats.originalNotesCount | number }}</b> - </div> - </div> - <div> - <span><fa icon="home"/> {{ $t('this-instance') }}</span> - <span @click="setChartSrc('notes')"><fa :icon="['far', 'chart-bar']"/></span> - </div> - </div> - <div> - <div> - <div><fa :icon="faDatabase"/></div> - <div> - <span>{{ $t('drive') }}</span> - <b>{{ stats.driveUsageLocal | bytes }}</b> - </div> - </div> - <div> - <span><fa icon="home"/> {{ $t('this-instance') }}</span> - <span @click="setChartSrc('drive')"><fa :icon="['far', 'chart-bar']"/></span> - </div> - </div> - <div> - <div> - <div><fa :icon="['far', 'hdd']"/></div> - <div> - <span>{{ $t('instances') }}</span> - <b>{{ stats.instances | number }}</b> - </div> - </div> - <div> - <span><fa icon="globe"/> {{ $t('federated') }}</span> - <span @click="setChartSrc('federation-instances-total')"><fa :icon="['far', 'chart-bar']"/></span> - </div> - </div> - </div> - - <div class="charts"> - <x-charts ref="charts"/> - </div> - - <div class="queue"> - <x-queue/> - </div> - - <div class="cpu-memory"> - <x-cpu-memory :connection="connection"/> - </div> - - <div class="ap"> - <x-ap-log/> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -import XCpuMemory from "./dashboard.cpu-memory.vue"; -import XQueue from "./dashboard.queue-charts.vue"; -import XCharts from "./dashboard.charts.vue"; -import XApLog from "./dashboard.ap-log.vue"; -import { faDatabase } from '@fortawesome/free-solid-svg-icons'; -import MarqueeText from 'vue-marquee-text-component'; -import randomColor from 'randomcolor'; - -export default Vue.extend({ - i18n: i18n('admin/views/dashboard.vue'), - - components: { - XCpuMemory, - XQueue, - XCharts, - XApLog, - MarqueeText - }, - - data() { - return { - stats: null, - connection: null, - meta: null, - instances: [], - faDatabase - }; - }, - - created() { - this.connection = this.$root.stream.useSharedConnection('serverStats'); - - this.updateStats(); - - this.$root.getMeta().then(meta => { - this.meta = meta; - }); - - this.$root.api('federation/instances', { - sort: '+notes' - }).then(instances => { - for (const i of instances) { - i.bg = randomColor({ - seed: i.host, - luminosity: 'dark' - }); - } - this.instances = instances; - }); - }, - - beforeDestroy() { - this.connection.dispose(); - }, - - methods: { - setChartSrc(src) { - this.$refs.charts.setSrc(src); - }, - - updateStats() { - this.$root.api('stats', {}, true).then(stats => { - this.stats = stats; - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.obdskegsannmntldydackcpzezagxqfy - padding 16px - - @media (min-width 500px) - padding 16px - - > header - display flex - padding-bottom 16px - border-bottom solid 1px var(--adminDashboardHeaderBorder) - color var(--adminDashboardHeaderFg) - font-size 14px - white-space nowrap - - @media (max-width 1000px) - display none - - > p - display block - margin 0 32px 0 0 - overflow hidden - text-overflow ellipsis - - > b - &:after - content ':' - margin-right 8px - - &:last-child - margin-left auto - margin-right 0 - - > .instances - padding 16px - color var(--adminDashboardHeaderFg) - font-size 13px - - >>> .instance - margin 0 10px - - > b - padding 2px 6px - margin-right 4px - border-radius 4px - color #fff - - > .stats - display flex - justify-content space-between - margin-bottom 16px - - > div - flex 1 - margin-right 16px - color var(--adminDashboardCardFg) - box-shadow 0 2px 4px rgba(0, 0, 0, 0.1) - background var(--adminDashboardCardBg) - border-radius 8px - - &:last-child - margin-right 0 - - > div:first-child - display flex - align-items center - text-align center - - &:last-child - margin-right 0 - - > div:first-child - padding 16px 24px - font-size 28px - - > div:last-child - flex 1 - padding 16px 32px 16px 0 - text-align right - - > span - font-size 70% - opacity 0.7 - - > b - display block - - > div:last-child - display flex - padding 6px 16px - border-top solid 1px var(--adminDashboardCardDivider) - - > span - font-size 70% - opacity 0.7 - - &:last-child - margin-left auto - cursor pointer - - @media (max-width 900px) - display grid - grid-template-columns 1fr 1fr - grid-template-rows 1fr 1fr - gap 16px - - > div - margin-right 0 - - @media (max-width 500px) - display block - - > div:not(:last-child) - margin-bottom 16px - - > .charts - margin-bottom 16px - - > .queue - margin-bottom 16px - - > .cpu-memory - margin-bottom 16px - -</style> diff --git a/src/client/app/admin/views/db.vue b/src/client/app/admin/views/db.vue deleted file mode 100644 index 9f87a749b6..0000000000 --- a/src/client/app/admin/views/db.vue +++ /dev/null @@ -1,61 +0,0 @@ -<template> -<div> - <ui-card> - <template #title><fa :icon="faDatabase"/> {{ $t('tables') }}</template> - <section v-if="tables"> - <div v-for="table in Object.keys(tables)"><b>{{ table }}</b> {{ tables[table].count | number }} {{ tables[table].size | bytes }}</div> - </section> - <section> - <header><fa :icon="faBroom"/> {{ $t('vacuum') }}</header> - <ui-info>{{ $t('vacuum-info') }}</ui-info> - <ui-switch v-model="fullVacuum">FULL</ui-switch> - <ui-switch v-model="analyzeVacuum">ANALYZE</ui-switch> - <ui-button @click="vacuum()"><fa :icon="faBroom"/> {{ $t('vacuum') }}</ui-button> - <ui-info warn>{{ $t('vacuum-exclamation') }}</ui-info> - </section> - </ui-card> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -import { faDatabase, faBroom } from '@fortawesome/free-solid-svg-icons'; - -export default Vue.extend({ - i18n: i18n('admin/views/db.vue'), - - data() { - return { - tables: null, - fullVacuum: true, - analyzeVacuum: true, - faDatabase, faBroom - }; - }, - - mounted() { - this.fetch(); - }, - - methods: { - fetch() { - this.$root.api('admin/get-table-stats').then(tables => { - this.tables = tables; - }); - }, - - vacuum() { - this.$root.api('admin/vacuum', { - full: this.fullVacuum, - analyze: this.analyzeVacuum, - }).then(() => { - this.$root.dialog({ - type: 'success', - splash: true - }); - }); - }, - } -}); -</script> diff --git a/src/client/app/admin/views/drive.vue b/src/client/app/admin/views/drive.vue deleted file mode 100644 index 1152db2b91..0000000000 --- a/src/client/app/admin/views/drive.vue +++ /dev/null @@ -1,292 +0,0 @@ -<template> -<div> - <ui-card> - <template #title><fa :icon="faTerminal"/> {{ $t('operation') }}</template> - <section class="fit-top"> - <ui-input v-model="target" type="text"> - <span>{{ $t('fileid-or-url') }}</span> - </ui-input> - <ui-horizon-group> - <ui-button @click="findAndToggleSensitive(true)"><fa :icon="faEyeSlash"/> {{ $t('mark-as-sensitive') }}</ui-button> - <ui-button @click="findAndToggleSensitive(false)"><fa :icon="faEye"/> {{ $t('unmark-as-sensitive') }}</ui-button> - </ui-horizon-group> - <ui-button @click="findAndDel()"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</ui-button> - <ui-button @click="show()"><fa :icon="faSearch"/> {{ $t('lookup') }}</ui-button> - <ui-textarea v-if="file" :value="file | json5" readonly tall style="margin-top:16px;"></ui-textarea> - </section> - <section> - <ui-button @click="cleanUp()"><fa :icon="faTrashAlt"/> {{ $t('clean-up') }}</ui-button> - <ui-button @click="cleanRemoteFiles()"><fa :icon="faTrashAlt"/> {{ $t('clean-remote-files') }}</ui-button> - </section> - </ui-card> - - <ui-card> - <template #title><fa :icon="faCloud"/> {{ $t('@.drive') }}</template> - <section class="fit-top"> - <ui-horizon-group inputs> - <ui-select v-model="sort"> - <template #label>{{ $t('sort.title') }}</template> - <option value="-createdAt">{{ $t('sort.createdAtAsc') }}</option> - <option value="+createdAt">{{ $t('sort.createdAtDesc') }}</option> - <option value="-size">{{ $t('sort.sizeAsc') }}</option> - <option value="+size">{{ $t('sort.sizeDesc') }}</option> - </ui-select> - <ui-select v-model="origin"> - <template #label>{{ $t('origin.title') }}</template> - <option value="combined">{{ $t('origin.combined') }}</option> - <option value="local">{{ $t('origin.local') }}</option> - <option value="remote">{{ $t('origin.remote') }}</option> - </ui-select> - </ui-horizon-group> - <sequential-entrance animation="entranceFromTop" delay="25"> - <div class="kidvdlkg" v-for="file in files"> - <div @click="file._open = !file._open"> - <div> - <x-file-thumbnail class="thumbnail" :file="file" fit="contain" @click="showFileMenu(file)"/> - </div> - <div> - <header> - <b>{{ file.name }}</b> - <span class="username">@{{ file.user | acct }}</span> - </header> - <div> - <div> - <span style="margin-right:16px;">{{ file.type }}</span> - <span>{{ file.size | bytes }}</span> - </div> - <div><mk-time :time="file.createdAt" mode="detail"/></div> - </div> - </div> - </div> - <div v-show="file._open"> - <ui-input readonly :value="file.url"></ui-input> - <ui-horizon-group> - <ui-button @click="toggleSensitive(file)" v-if="file.isSensitive"><fa :icon="faEye"/> {{ $t('unmark-as-sensitive') }}</ui-button> - <ui-button @click="toggleSensitive(file)" v-else><fa :icon="faEyeSlash"/> {{ $t('mark-as-sensitive') }}</ui-button> - <ui-button @click="del(file)"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</ui-button> - </ui-horizon-group> - </div> - </div> - </sequential-entrance> - <ui-button v-if="existMore" @click="fetch">{{ $t('@.load-more') }}</ui-button> - </section> - </ui-card> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -import { faCloud, faTerminal, faSearch } from '@fortawesome/free-solid-svg-icons'; -import { faTrashAlt, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; -import XFileThumbnail from '../../common/views/components/drive-file-thumbnail.vue'; - -export default Vue.extend({ - i18n: i18n('admin/views/drive.vue'), - - components: { - XFileThumbnail - }, - - data() { - return { - file: null, - target: null, - sort: '+createdAt', - origin: 'combined', - limit: 10, - offset: 0, - files: [], - existMore: false, - faCloud, faTrashAlt, faEye, faEyeSlash, faTerminal, faSearch - }; - }, - - watch: { - sort() { - this.files = []; - this.offset = 0; - this.fetch(); - }, - - origin() { - this.files = []; - this.offset = 0; - this.fetch(); - } - }, - - mounted() { - this.fetch(); - }, - - methods: { - async fetchFile() { - try { - return await this.$root.api('drive/files/show', this.target.startsWith('http') ? { url: this.target } : { fileId: this.target }); - } catch (e) { - if (e == 'file-not-found') { - this.$root.dialog({ - type: 'error', - text: this.$t('file-not-found') - }); - } else { - this.$root.dialog({ - type: 'error', - text: e.toString() - }); - } - } - }, - - fetch() { - this.$root.api('admin/drive/files', { - origin: this.origin, - sort: this.sort, - offset: this.offset, - limit: this.limit + 1 - }).then(files => { - if (files.length == this.limit + 1) { - files.pop(); - this.existMore = true; - } else { - this.existMore = false; - } - for (const x of files) { - x._open = false; - } - this.files = this.files.concat(files); - this.offset += this.limit; - }); - }, - - async del(file: any) { - const process = async () => { - await this.$root.api('drive/files/delete', { fileId: file.id }); - this.$root.dialog({ - type: 'success', - text: this.$t('deleted') - }); - }; - - await process().catch(e => { - this.$root.dialog({ - type: 'error', - text: e.toString() - }); - }); - }, - - toggleSensitive(file: any) { - this.$root.api('drive/files/update', { - fileId: file.id, - isSensitive: !file.isSensitive - }).then(() => { - file.isSensitive = !file.isSensitive; - }); - }, - - async show() { - const file = await this.fetchFile(); - this.$root.api('admin/drive/show-file', { fileId: file.id }).then(info => { - this.file = info; - }); - }, - - async findAndToggleSensitive(sensitive) { - const process = async () => { - const file = await this.fetchFile(); - await this.$root.api('drive/files/update', { - fileId: file.id, - isSensitive: sensitive - }); - this.$root.dialog({ - type: 'success', - text: sensitive ? this.$t('marked-as-sensitive') : this.$t('unmarked-as-sensitive') - }); - }; - - await process().catch(e => { - this.$root.dialog({ - type: 'error', - text: e.toString() - }); - }); - }, - - async findAndDel() { - const process = async () => { - const file = await this.fetchFile(); - await this.$root.api('drive/files/delete', { fileId: file.id }); - this.$root.dialog({ - type: 'success', - text: this.$t('deleted') - }); - }; - - await process().catch(e => { - this.$root.dialog({ - type: 'error', - text: e.toString() - }); - }); - }, - - cleanRemoteFiles() { - this.$root.dialog({ - type: 'warning', - text: this.$t('clean-remote-files-are-you-sure'), - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) return; - this.$root.api('admin/drive/clean-remote-files'); - this.$root.dialog({ - type: 'success', - splash: true - }); - }); - }, - - cleanUp() { - this.$root.api('admin/drive/cleanup'); - this.$root.dialog({ - type: 'success', - splash: true - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.kidvdlkg - padding 16px 0 - border-top solid 1px var(--faceDivider) - - > div:first-child - display flex - cursor pointer - - > div:nth-child(1) - > .thumbnail - display flex - width 64px - height 64px - background-size cover - background-position center center - - > div:nth-child(2) - flex 1 - padding-left 16px - - @media (max-width 500px) - font-size 14px - - > header - word-break break-word - - > .username - margin-left 8px - opacity 0.7 - -</style> diff --git a/src/client/app/admin/views/emoji.vue b/src/client/app/admin/views/emoji.vue deleted file mode 100644 index 2925fcab57..0000000000 --- a/src/client/app/admin/views/emoji.vue +++ /dev/null @@ -1,185 +0,0 @@ -<template> -<div> - <ui-card> - <template #title><fa icon="plus"/> {{ $t('add-emoji.title') }}</template> - <section class="fit-top"> - <ui-horizon-group inputs> - <ui-input v-model="name"> - <span>{{ $t('add-emoji.name') }}</span> - <template #desc>{{ $t('add-emoji.name-desc') }}</template> - </ui-input> - <ui-input v-model="category" :datalist="categoryList"> - <span>{{ $t('add-emoji.category') }}</span> - </ui-input> - <ui-input v-model="aliases"> - <span>{{ $t('add-emoji.aliases') }}</span> - <template #desc>{{ $t('add-emoji.aliases-desc') }}</template> - </ui-input> - </ui-horizon-group> - <ui-input v-model="url"> - <template #icon><fa icon="link"/></template> - <span>{{ $t('add-emoji.url') }}</span> - </ui-input> - <ui-info>{{ $t('add-emoji.info') }}</ui-info> - <ui-button @click="add">{{ $t('add-emoji.add') }}</ui-button> - </section> - </ui-card> - - <ui-card> - <template #title><fa :icon="faGrin"/> {{ $t('emojis.title') }}</template> - <section v-for="emoji in emojis" :key="emoji.name" class="oryfrbft"> - <div> - <img :src="emoji.url" :alt="emoji.name" style="width: 64px;"/> - </div> - <div> - <ui-horizon-group> - <ui-input v-model="emoji.name"> - <span>{{ $t('add-emoji.name') }}</span> - </ui-input> - <ui-input v-model="emoji.category" :datalist="categoryList"> - <span>{{ $t('add-emoji.category') }}</span> - </ui-input> - <ui-input v-model="emoji.aliases"> - <span>{{ $t('add-emoji.aliases') }}</span> - </ui-input> - </ui-horizon-group> - <ui-input v-model="emoji.url"> - <template #icon><fa icon="link"/></template> - <span>{{ $t('add-emoji.url') }}</span> - </ui-input> - <ui-horizon-group class="fit-bottom"> - <ui-button @click="updateEmoji(emoji)"><fa :icon="['far', 'save']"/> {{ $t('emojis.update') }}</ui-button> - <ui-button @click="removeEmoji(emoji)"><fa :icon="['far', 'trash-alt']"/> {{ $t('emojis.remove') }}</ui-button> - </ui-horizon-group> - </div> - </section> - </ui-card> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -import { faGrin } from '@fortawesome/free-regular-svg-icons'; -import { unique } from '../../../../prelude/array'; - -export default Vue.extend({ - i18n: i18n('admin/views/emoji.vue'), - data() { - return { - name: '', - category: '', - url: '', - aliases: '', - emojis: [], - faGrin - }; - }, - - mounted() { - this.fetchEmojis(); - }, - - computed: { - categoryList() { - return unique(this.emojis.map((x: any) => x.category || '').filter((x: string) => x !== '')); - } - }, - - methods: { - add() { - this.$root.api('admin/emoji/add', { - name: this.name, - category: this.category, - url: this.url, - aliases: this.aliases.split(' ').filter(x => x.length > 0) - }).then(() => { - this.$root.dialog({ - type: 'success', - text: this.$t('add-emoji.added') - }); - this.fetchEmojis(); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - }, - - fetchEmojis() { - this.$root.api('admin/emoji/list').then(emojis => { - for (const e of emojis) { - e.aliases = (e.aliases || []).join(' '); - } - this.emojis = emojis; - }); - }, - - updateEmoji(emoji) { - this.$root.api('admin/emoji/update', { - id: emoji.id, - name: emoji.name, - category: emoji.category, - url: emoji.url, - aliases: emoji.aliases.split(' ').filter(x => x.length > 0) - }).then(() => { - this.$root.dialog({ - type: 'success', - text: this.$t('updated') - }); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - }, - - removeEmoji(emoji) { - this.$root.dialog({ - type: 'warning', - text: this.$t('remove-emoji.are-you-sure').replace('$1', emoji.name), - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) return; - - this.$root.api('admin/emoji/remove', { - id: emoji.id - }).then(() => { - this.$root.dialog({ - type: 'success', - text: this.$t('remove-emoji.removed') - }); - this.fetchEmojis(); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.oryfrbft - @media (min-width 500px) - display flex - - > div:first-child - @media (max-width 500px) - padding-bottom 16px - - > img - vertical-align bottom - - > div:last-child - flex 1 - - @media (min-width 500px) - padding-left 16px - -</style> diff --git a/src/client/app/admin/views/federation.vue b/src/client/app/admin/views/federation.vue deleted file mode 100644 index b419cca1d7..0000000000 --- a/src/client/app/admin/views/federation.vue +++ /dev/null @@ -1,553 +0,0 @@ -<template> -<div> - <ui-card> - <template #title><fa :icon="faTerminal"/> {{ $t('instance') }}</template> - <section class="fit-top"> - <ui-input class="target" v-model="target" type="text" @enter="showInstance()"> - <span>{{ $t('host') }}</span> - <template #prefix><fa :icon="faServer"/></template> - </ui-input> - <ui-button @click="showInstance()"><fa :icon="faSearch"/> {{ $t('lookup') }}</ui-button> - - <div class="instance" v-if="instance"> - <ui-horizon-group inputs> - <ui-input :value="instance.host" type="text" readonly> - <span>{{ $t('host') }}</span> - <template #prefix><fa :icon="faServer"/></template> - </ui-input> - <ui-input :value="instance.caughtAt | date" type="text" readonly> - <span>{{ $t('caught-at') }}</span> - <template #prefix><fa :icon="faCrosshairs"/></template> - </ui-input> - </ui-horizon-group> - <ui-horizon-group inputs> - <ui-input :value="instance.notesCount | number" type="text" readonly> - <span>{{ $t('notes') }}</span> - <template #prefix><fa :icon="faEnvelopeOpenText"/></template> - </ui-input> - <ui-input :value="instance.usersCount | number" type="text" readonly> - <span>{{ $t('users') }}</span> - <template #prefix><fa :icon="faUsers"/></template> - </ui-input> - </ui-horizon-group> - <ui-horizon-group inputs> - <ui-input :value="instance.followingCount | number" type="text" readonly> - <span>{{ $t('following') }}</span> - <template #prefix><fa :icon="faCaretDown"/></template> - </ui-input> - <ui-input :value="instance.followersCount | number" type="text" readonly> - <span>{{ $t('followers') }}</span> - <template #prefix><fa :icon="faCaretUp"/></template> - </ui-input> - </ui-horizon-group> - <ui-horizon-group inputs> - <ui-input :value="instance.latestRequestSentAt | date" type="text" readonly> - <span>{{ $t('latest-request-sent-at') }}</span> - <template #prefix><fa :icon="faPaperPlane"/></template> - </ui-input> - <ui-input :value="instance.latestStatus" type="text" readonly> - <span>{{ $t('status') }}</span> - <template #prefix><fa :icon="faTrafficLight"/></template> - </ui-input> - </ui-horizon-group> - <ui-input :value="instance.latestRequestReceivedAt | date" type="text" readonly> - <span>{{ $t('latest-request-received-at') }}</span> - <template #prefix><fa :icon="faInbox"/></template> - </ui-input> - <ui-switch v-model="instance.isMarkedAsClosed" @change="updateInstance()">{{ $t('marked-as-closed') }}</ui-switch> - <details> - <summary>{{ $t('charts') }}</summary> - <ui-horizon-group inputs> - <ui-select v-model="chartSrc"> - <option value="requests">{{ $t('chart-srcs.requests') }}</option> - <option value="users">{{ $t('chart-srcs.users') }}</option> - <option value="users-total">{{ $t('chart-srcs.users-total') }}</option> - <option value="notes">{{ $t('chart-srcs.notes') }}</option> - <option value="notes-total">{{ $t('chart-srcs.notes-total') }}</option> - <option value="ff">{{ $t('chart-srcs.ff') }}</option> - <option value="ff-total">{{ $t('chart-srcs.ff-total') }}</option> - <option value="drive-usage">{{ $t('chart-srcs.drive-usage') }}</option> - <option value="drive-usage-total">{{ $t('chart-srcs.drive-usage-total') }}</option> - <option value="drive-files">{{ $t('chart-srcs.drive-files') }}</option> - <option value="drive-files-total">{{ $t('chart-srcs.drive-files-total') }}</option> - </ui-select> - <ui-select v-model="chartSpan"> - <option value="hour">{{ $t('chart-spans.hour') }}</option> - <option value="day">{{ $t('chart-spans.day') }}</option> - </ui-select> - </ui-horizon-group> - <div ref="chart"></div> - </details> - <details> - <summary>{{ $t('delete-all-files') }}</summary> - <ui-button @click="deleteAllFiles()" style="margin-top: 16px;"><fa :icon="faTrashAlt"/> {{ $t('delete-all-files') }}</ui-button> - </details> - <details> - <summary>{{ $t('remove-all-following') }}</summary> - <ui-button @click="removeAllFollowing()" style="margin-top: 16px;"><fa :icon="faMinusCircle"/> {{ $t('remove-all-following') }}</ui-button> - <ui-info warn>{{ $t('remove-all-following-info', { host: instance.host }) }}</ui-info> - </details> - </div> - </section> - </ui-card> - - <ui-card> - <template #title><fa :icon="faServer"/> {{ $t('instances') }}</template> - <section class="fit-top"> - <ui-horizon-group inputs> - <ui-select v-model="sort"> - <template #label>{{ $t('sort') }}</template> - <option value="-caughtAt">{{ $t('sorts.caughtAtAsc') }}</option> - <option value="+caughtAt">{{ $t('sorts.caughtAtDesc') }}</option> - <option value="-lastCommunicatedAt">{{ $t('sorts.lastCommunicatedAtAsc') }}</option> - <option value="+lastCommunicatedAt">{{ $t('sorts.lastCommunicatedAtDesc') }}</option> - <option value="-notes">{{ $t('sorts.notesAsc') }}</option> - <option value="+notes">{{ $t('sorts.notesDesc') }}</option> - <option value="-users">{{ $t('sorts.usersAsc') }}</option> - <option value="+users">{{ $t('sorts.usersDesc') }}</option> - <option value="-following">{{ $t('sorts.followingAsc') }}</option> - <option value="+following">{{ $t('sorts.followingDesc') }}</option> - <option value="-followers">{{ $t('sorts.followersAsc') }}</option> - <option value="+followers">{{ $t('sorts.followersDesc') }}</option> - <option value="-driveUsage">{{ $t('sorts.driveUsageAsc') }}</option> - <option value="+driveUsage">{{ $t('sorts.driveUsageDesc') }}</option> - <option value="-driveFiles">{{ $t('sorts.driveFilesAsc') }}</option> - <option value="+driveFiles">{{ $t('sorts.driveFilesDesc') }}</option> - </ui-select> - <ui-select v-model="state"> - <template #label>{{ $t('state') }}</template> - <option value="all">{{ $t('states.all') }}</option> - <option value="blocked">{{ $t('states.blocked') }}</option> - <option value="notResponding">{{ $t('states.not-responding') }}</option> - <option value="markedAsClosed">{{ $t('states.marked-as-closed') }}</option> - </ui-select> - </ui-horizon-group> - - <div class="instances"> - <header> - <span>{{ $t('host') }}</span> - <span>{{ $t('notes') }}</span> - <span>{{ $t('users') }}</span> - <span>{{ $t('following') }}</span> - <span>{{ $t('followers') }}</span> - <span>{{ $t('status') }}</span> - </header> - <div v-for="instance in instances" :style="{ opacity: instance.isNotResponding ? 0.5 : 1 }"> - <a @click.prevent="showInstance(instance.host)" rel="nofollow noopener" target="_blank" :href="`https://${instance.host}`" :style="{ textDecoration: instance.isMarkedAsClosed ? 'line-through' : 'none' }">{{ instance.host }}</a> - <span>{{ instance.notesCount | number }}</span> - <span>{{ instance.usersCount | number }}</span> - <span>{{ instance.followingCount | number }}</span> - <span>{{ instance.followersCount | number }}</span> - <span>{{ instance.latestStatus }}</span> - </div> - </div> - - <ui-info v-if="instances.length == limit">{{ $t('result-is-truncated', { n: limit }) }}</ui-info> - </section> - </ui-card> - - <ui-card> - <template #title><fa :icon="faBan"/> {{ $t('blocked-hosts') }}</template> - <section class="fit-top"> - <ui-textarea v-model="blockedHosts"> - <template #desc>{{ $t('blocked-hosts-info') }}</template> - </ui-textarea> - <ui-button @click="saveBlockedHosts">{{ $t('save') }}</ui-button> - </section> - </ui-card> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -import { faPaperPlane } from '@fortawesome/free-regular-svg-icons'; -import { faTrashAlt, faBan, faGlobe, faTerminal, faSearch, faMinusCircle, faServer, faCrosshairs, faEnvelopeOpenText, faUsers, faCaretDown, faCaretUp, faTrafficLight, faInbox } from '@fortawesome/free-solid-svg-icons'; -import ApexCharts from 'apexcharts'; -import * as tinycolor from 'tinycolor2'; - -const chartLimit = 90; -const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); -const negate = arr => arr.map(x => -x); - -export default Vue.extend({ - i18n: i18n('admin/views/federation.vue'), - - filters: { - date: v => v ? new Date(v).toLocaleString() : 'N/A' - }, - - data() { - return { - instance: null, - target: null, - sort: '+lastCommunicatedAt', - state: 'all', - limit: 100, - instances: [], - chart: null, - chartSrc: 'requests', - chartSpan: 'hour', - chartInstance: null, - blockedHosts: '', - faTrashAlt, faBan, faGlobe, faTerminal, faSearch, faMinusCircle, faServer, faCrosshairs, faEnvelopeOpenText, faUsers, faCaretDown, faCaretUp, faPaperPlane, faTrafficLight, faInbox - }; - }, - - computed: { - data(): any { - if (this.chart == null) return null; - switch (this.chartSrc) { - case 'requests': return this.requestsChart(); - case 'users': return this.usersChart(false); - case 'users-total': return this.usersChart(true); - case 'notes': return this.notesChart(false); - case 'notes-total': return this.notesChart(true); - case 'ff': return this.ffChart(false); - case 'ff-total': return this.ffChart(true); - case 'drive-usage': return this.driveUsageChart(false); - case 'drive-usage-total': return this.driveUsageChart(true); - case 'drive-files': return this.driveFilesChart(false); - case 'drive-files-total': return this.driveFilesChart(true); - } - }, - - stats(): any[] { - const stats = - this.chartSpan == 'day' ? this.chart.perDay : - this.chartSpan == 'hour' ? this.chart.perHour : - null; - - return stats; - } - }, - - watch: { - sort() { - this.fetchInstances(); - }, - - state() { - this.fetchInstances(); - }, - - async instance() { - this.now = new Date(); - - const [perHour, perDay] = await Promise.all([ - this.$root.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'hour' }), - this.$root.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'day' }), - ]); - - const chart = { - perHour: perHour, - perDay: perDay - }; - - this.chart = chart; - - this.renderChart(); - }, - - chartSrc() { - this.renderChart(); - }, - - chartSpan() { - this.renderChart(); - } - }, - - mounted() { - this.fetchInstances(); - - this.$root.getMeta().then(meta => { - this.blockedHosts = meta.blockedHosts.join('\n'); - }); - }, - - beforeDestroy() { - this.chartInstance.destroy(); - }, - - methods: { - showInstance(target?: string) { - this.$root.api('federation/show-instance', { - host: target || this.target - }).then(instance => { - if (instance == null) { - this.$root.dialog({ - type: 'error', - text: this.$t('instance-not-registered') - }); - } else { - this.instance = instance; - this.target = ''; - } - }); - }, - - fetchInstances() { - this.instances = []; - this.$root.api('federation/instances', { - blocked: this.state === 'blocked' ? true : null, - notResponding: this.state === 'notResponding' ? true : null, - markedAsClosed: this.state === 'markedAsClosed' ? true : null, - sort: this.sort, - limit: this.limit - }).then(instances => { - this.instances = instances; - }); - }, - - removeAllFollowing() { - this.$root.api('admin/federation/remove-all-following', { - host: this.instance.host - }).then(() => { - this.$root.dialog({ - type: 'success', - splash: true - }); - }); - }, - - deleteAllFiles() { - this.$root.api('admin/federation/delete-all-files', { - host: this.instance.host - }).then(() => { - this.$root.dialog({ - type: 'success', - splash: true - }); - }); - }, - - updateInstance() { - this.$root.api('admin/federation/update-instance', { - host: this.instance.host, - isBlocked: this.instance.isBlocked || false, - isClosed: this.instance.isMarkedAsClosed || false - }); - }, - - setSrc(src) { - this.chartSrc = src; - }, - - renderChart() { - if (this.chartInstance) { - this.chartInstance.destroy(); - } - - this.chartInstance = new ApexCharts(this.$refs.chart, { - chart: { - type: 'area', - height: 300, - animations: { - dynamicAnimation: { - enabled: false - } - }, - toolbar: { - show: false - }, - zoom: { - enabled: false - } - }, - dataLabels: { - enabled: false - }, - grid: { - clipMarkers: false, - borderColor: 'rgba(0, 0, 0, 0.1)' - }, - stroke: { - curve: 'straight', - width: 2 - }, - tooltip: { - theme: this.$store.state.device.darkmode ? 'dark' : 'light' - }, - legend: { - labels: { - colors: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString() - }, - }, - xaxis: { - type: 'datetime', - labels: { - style: { - colors: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString() - } - }, - axisBorder: { - color: 'rgba(0, 0, 0, 0.1)' - }, - axisTicks: { - color: 'rgba(0, 0, 0, 0.1)' - }, - }, - yaxis: { - labels: { - formatter: this.data.bytes ? v => Vue.filter('bytes')(v, 0) : v => Vue.filter('number')(v), - style: { - color: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString() - } - } - }, - series: this.data.series - }); - - this.chartInstance.render(); - }, - - getDate(i: number) { - const y = this.now.getFullYear(); - const m = this.now.getMonth(); - const d = this.now.getDate(); - const h = this.now.getHours(); - - return ( - this.chartSpan == 'day' ? new Date(y, m, d - i) : - this.chartSpan == 'hour' ? new Date(y, m, d, h - i) : - null - ); - }, - - format(arr) { - return arr.map((v, i) => ({ x: this.getDate(i).getTime(), y: v })); - }, - - requestsChart(): any { - return { - series: [{ - name: 'Incoming', - data: this.format(this.stats.requests.received) - }, { - name: 'Outgoing (succeeded)', - data: this.format(this.stats.requests.succeeded) - }, { - name: 'Outgoing (failed)', - data: this.format(this.stats.requests.failed) - }] - }; - }, - - usersChart(total: boolean): any { - return { - series: [{ - name: 'Users', - type: 'area', - data: this.format(total - ? this.stats.users.total - : sum(this.stats.users.inc, negate(this.stats.users.dec)) - ) - }] - }; - }, - - notesChart(total: boolean): any { - return { - series: [{ - name: 'Notes', - type: 'area', - data: this.format(total - ? this.stats.notes.total - : sum(this.stats.notes.inc, negate(this.stats.notes.dec)) - ) - }] - }; - }, - - ffChart(total: boolean): any { - return { - series: [{ - name: 'Following', - type: 'area', - data: this.format(total - ? this.stats.following.total - : sum(this.stats.following.inc, negate(this.stats.following.dec)) - ) - }, { - name: 'Followers', - type: 'area', - data: this.format(total - ? this.stats.followers.total - : sum(this.stats.followers.inc, negate(this.stats.followers.dec)) - ) - }] - }; - }, - - driveUsageChart(total: boolean): any { - return { - bytes: true, - series: [{ - name: 'Drive usage', - type: 'area', - data: this.format(total - ? this.stats.drive.totalUsage - : sum(this.stats.drive.incUsage, negate(this.stats.drive.decUsage)) - ) - }] - }; - }, - - driveFilesChart(total: boolean): any { - return { - series: [{ - name: 'Drive files', - type: 'area', - data: this.format(total - ? this.stats.drive.totalFiles - : sum(this.stats.drive.incFiles, negate(this.stats.drive.decFiles)) - ) - }] - }; - }, - - saveBlockedHosts() { - this.$root.api('admin/update-meta', { - blockedHosts: this.blockedHosts ? this.blockedHosts.split('\n') : [] - }).then(() => { - this.$root.dialog({ - type: 'success', - text: this.$t('saved') - }); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.target - margin-bottom 16px !important - -.instances - width 100% - - > header - display flex - - > * - color var(--text) - font-weight bold - - > div - display flex - - > * > * - flex 1 - overflow auto - - &:first-child - min-width 200px - -</style> diff --git a/src/client/app/admin/views/index.vue b/src/client/app/admin/views/index.vue deleted file mode 100644 index 1b81185749..0000000000 --- a/src/client/app/admin/views/index.vue +++ /dev/null @@ -1,297 +0,0 @@ -<template> -<div class="mk-admin" :class="{ isMobile }"> - <header v-show="isMobile"> - <button class="nav" @click="navOpend = true"><fa icon="bars"/></button> - <span>MisskeyMyAdmin</span> - </header> - <div class="nav-backdrop" - v-if="navOpend && isMobile" - @click="navOpend = false" - @touchstart="navOpend = false" - ></div> - <nav v-show="navOpend"> - <div class="mi"> - <img svg-inline src="../assets/header-icon.svg"/> - </div> - <div class="me"> - <img class="avatar" :src="$store.state.i.avatarUrl" alt="avatar"/> - <p class="name"><mk-user-name :user="$store.state.i"/></p> - </div> - <ul> - <li><router-link to="/dashboard" active-class="active"><fa icon="home" fixed-width/>{{ $t('dashboard') }}</router-link></li> - <li><router-link to="/instance" active-class="active"><fa icon="cog" fixed-width/>{{ $t('instance') }}</router-link></li> - <li><router-link to="/queue" active-class="active"><fa :icon="faTasks" fixed-width/>{{ $t('queue') }}</router-link></li> - <li><router-link to="/logs" active-class="active"><fa :icon="faStream" fixed-width/>{{ $t('logs') }}</router-link></li> - <li><router-link to="/db" active-class="active"><fa :icon="faDatabase" fixed-width/>{{ $t('db') }}</router-link></li> - <li><router-link to="/moderators" active-class="active"><fa :icon="faHeadset" fixed-width/>{{ $t('moderators') }}</router-link></li> - <li><router-link to="/users" active-class="active"><fa icon="users" fixed-width/>{{ $t('users') }}</router-link></li> - <li><router-link to="/drive" active-class="active"><fa icon="cloud" fixed-width/>{{ $t('@.drive') }}</router-link></li> - <li><router-link to="/federation" active-class="active"><fa :icon="faGlobe" fixed-width/>{{ $t('federation') }}</router-link></li> - <li><router-link to="/emoji" active-class="active"><fa :icon="faGrin" fixed-width/>{{ $t('emoji') }}</router-link></li> - <li><router-link to="/announcements" active-class="active"><fa icon="broadcast-tower" fixed-width/>{{ $t('announcements') }}</router-link></li> - <li><router-link to="/abuse" active-class="active"><fa :icon="faExclamationCircle" fixed-width/>{{ $t('abuse') }}</router-link></li> - </ul> - <div class="back-to-misskey"> - <a href="/"><fa :icon="faArrowLeft"/> {{ $t('back-to-misskey') }}</a> - </div> - <div class="version"> - <small>Misskey {{ version }}</small> - </div> - </nav> - <main> - <div class="page"> - <div v-if="page == 'dashboard'"><x-dashboard/></div> - <div v-if="page == 'instance'"><x-instance/></div> - <div v-if="page == 'queue'"><x-queue/></div> - <div v-if="page == 'logs'"><x-logs/></div> - <div v-if="page == 'db'"><x-db/></div> - <div v-if="page == 'moderators'"><x-moderators/></div> - <div v-if="page == 'users'"><x-users/></div> - <div v-if="page == 'emoji'"><x-emoji/></div> - <div v-if="page == 'announcements'"><x-announcements/></div> - <div v-if="page == 'drive'"><x-drive/></div> - <div v-if="page == 'federation'"><x-federation/></div> - <div v-if="page == 'abuse'"><x-abuse/></div> - </div> - </main> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -import { version } from '../../config'; -import XDashboard from './dashboard.vue'; -import XInstance from './instance.vue'; -import XQueue from './queue.vue'; -import XLogs from './logs.vue'; -import XDb from './db.vue'; -import XModerators from './moderators.vue'; -import XEmoji from './emoji.vue'; -import XAnnouncements from './announcements.vue'; -import XUsers from './users.vue'; -import XDrive from './drive.vue'; -import XAbuse from './abuse.vue'; -import XFederation from './federation.vue'; - -import { faHeadset, faArrowLeft, faGlobe, faExclamationCircle, faTasks, faStream, faDatabase } from '@fortawesome/free-solid-svg-icons'; -import { faGrin } from '@fortawesome/free-regular-svg-icons'; - -// Detect the user agent -const ua = navigator.userAgent.toLowerCase(); -const isMobile = /mobile|iphone|ipad|android/.test(ua); - -export default Vue.extend({ - i18n: i18n('admin/views/index.vue'), - components: { - XDashboard, - XInstance, - XQueue, - XLogs, - XDb, - XModerators, - XEmoji, - XAnnouncements, - XUsers, - XDrive, - XAbuse, - XFederation, - }, - provide: { - isMobile - }, - data() { - return { - version, - isMobile, - navOpend: !isMobile, - faGrin, - faArrowLeft, - faHeadset, - faGlobe, - faExclamationCircle, - faTasks, - faStream, - faDatabase, - }; - }, - computed: { - page() { - return this.$route.params.page; - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-admin - $headerHeight = 48px - - display flex - height 100% - - > header - position fixed - top 0 - z-index 10000 - width 100% - color var(--mobileHeaderFg) - background-color var(--mobileHeaderBg) - box-shadow 0 1px 0 rgba(#000, 0.075) - - &, * - user-select none - - > span - display block - line-height $headerHeight - text-align center - - > .nav - display block - position absolute - top 0 - left 0 - z-index 10001 - padding 0 - width $headerHeight - font-size 1.4em - line-height $headerHeight - border-right solid 1px rgba(#000, 0.1) - - > [data-icon] - transition all 0.2s ease - - > nav - position fixed - z-index 20001 - top 0 - left 0 - width 250px - height 100vh - overflow auto - background #333 - color #fff - - > .mi - text-align center - - > svg - width 24px - height 82px - vertical-align top - fill #fff - opacity 0.7 - - > .me - display flex - margin 0 16px 16px 16px - padding 16px 0 - align-items center - border-top solid 1px #555 - border-bottom solid 1px #555 - - > .avatar - height 48px - border-radius 100% - vertical-align middle - - > .name - margin 0 16px - padding 0 - color #fff - overflow hidden - text-overflow ellipsis - white-space nowrap - font-size 15px - - > .back-to-misskey - margin 16px 16px 0 16px - padding 0 - border-top solid 1px #555 - - > a - display block - padding 16px 4px - color inherit - text-decoration none - color #eee - font-size 15px - - &:hover - color #fff - - > [data-icon] - margin-right 6px - - > .version - margin 0 16px 16px 16px - padding-top 16px - border-top solid 1px #555 - text-align center - - > small - opacity 0.7 - - > ul - margin 0 - padding 0 - list-style none - font-size 15px - - > li > a - display block - padding 10px 16px - margin 0 - user-select none - color #eee - transition margin-left 0.2s ease - - &:hover - color #fff - - > [data-icon] - margin-right 6px - - &.active - margin-left 8px - color var(--primary) !important - - &:after - content "" - display block - position absolute - top 0 - right 0 - bottom 0 - margin auto 0 - height 0 - border-top solid 16px transparent - border-right solid 16px var(--bg) - border-bottom solid 16px transparent - border-left solid 16px transparent - - > .nav-backdrop - position fixed - top 0 - left 0 - z-index 20000 - width 100% - height 100% - background var(--mobileNavBackdrop) - - > main - width 100% - padding 0 0 0 250px - - > .page - max-width 1150px - - @media (min-width 500px) - padding 16px - - &.isMobile - > main - padding $headerHeight 0 0 0 - -</style> diff --git a/src/client/app/admin/views/instance.vue b/src/client/app/admin/views/instance.vue deleted file mode 100644 index ebc554f955..0000000000 --- a/src/client/app/admin/views/instance.vue +++ /dev/null @@ -1,523 +0,0 @@ -<template> -<div> - <ui-card> - <template #title><fa icon="cog"/> {{ $t('instance') }}</template> - <section class="fit-top"> - <ui-input :value="host" readonly>{{ $t('host') }}</ui-input> - <ui-input v-model="name">{{ $t('instance-name') }}</ui-input> - <ui-textarea v-model="description">{{ $t('instance-description') }}</ui-textarea> - <ui-input v-model="iconUrl"><template #icon><fa icon="link"/></template>{{ $t('icon-url') }}</ui-input> - <ui-input v-model="mascotImageUrl"><template #icon><fa icon="link"/></template>{{ $t('logo-url') }}</ui-input> - <ui-input v-model="bannerUrl"><template #icon><fa icon="link"/></template>{{ $t('banner-url') }}</ui-input> - <ui-input v-model="ToSUrl"><template #icon><fa icon="link"/></template>{{ $t('tos-url') }}</ui-input> - <details> - <summary>{{ $t('advanced-config') }}</summary> - <ui-input v-model="errorImageUrl"><template #icon><fa icon="link"/></template>{{ $t('error-image-url') }}</ui-input> - <ui-input v-model="languages"><template #icon><fa icon="language"/></template>{{ $t('languages') }}<template #desc>{{ $t('languages-desc') }}</template></ui-input> - <ui-input v-model="repositoryUrl"><template #icon><fa icon="link"/></template>{{ $t('repository-url') }}</ui-input> - <ui-input v-model="feedbackUrl"><template #icon><fa icon="link"/></template>{{ $t('feedback-url') }}</ui-input> - </details> - </section> - <section class="fit-bottom"> - <header><fa :icon="faHeadset"/> {{ $t('maintainer-config') }}</header> - <ui-input v-model="maintainerName">{{ $t('maintainer-name') }}</ui-input> - <ui-input v-model="maintainerEmail" type="email"><template #icon><fa :icon="farEnvelope"/></template>{{ $t('maintainer-email') }}</ui-input> - </section> - <section> - <ui-switch v-model="disableRegistration">{{ $t('disable-registration') }}</ui-switch> - <ui-button v-if="disableRegistration" @click="invite">{{ $t('invite') }}</ui-button> - </section> - <section> - <ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button> - </section> - </ui-card> - - <ui-card> - <template #title><fa :icon="faPencilAlt"/> {{ $t('note-and-tl') }}</template> - <section class="fit-top fit-bottom"> - <ui-input v-model="maxNoteTextLength">{{ $t('max-note-text-length') }}</ui-input> - </section> - <section> - <ui-switch v-model="disableLocalTimeline">{{ $t('disable-local-timeline') }}</ui-switch> - <ui-switch v-model="disableGlobalTimeline">{{ $t('disable-global-timeline') }}</ui-switch> - <ui-info>{{ $t('disabling-timelines-info') }}</ui-info> - </section> - <section> - <ui-switch v-model="enableEmojiReaction">{{ $t('enable-emoji-reaction') }}</ui-switch> - <ui-switch v-model="useStarForReactionFallback">{{ $t('use-star-for-reaction-fallback') }}</ui-switch> - </section> - <section> - <ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button> - </section> - </ui-card> - - <ui-card> - <template #title><fa icon="cloud"/> {{ $t('drive-config') }}</template> - <section> - <ui-switch v-model="useObjectStorage">{{ $t('use-object-storage') }}</ui-switch> - <template v-if="useObjectStorage"> - <ui-info> - <i18n path="object-storage-s3-info"> - <a href="https://docs.aws.amazon.com/general/latest/gr/rande.html" target="_blank">{{ $t('object-storage-s3-info-here') }}</a> - </i18n> - </ui-info> - <ui-info>{{ $t('object-storage-gcs-info') }}</ui-info> - <ui-input v-model="objectStorageBaseUrl" :disabled="!useObjectStorage">{{ $t('object-storage-base-url') }}</ui-input> - <ui-horizon-group inputs> - <ui-input v-model="objectStorageBucket" :disabled="!useObjectStorage">{{ $t('object-storage-bucket') }}</ui-input> - <ui-input v-model="objectStoragePrefix" :disabled="!useObjectStorage">{{ $t('object-storage-prefix') }}</ui-input> - </ui-horizon-group> - <ui-input v-model="objectStorageEndpoint" :disabled="!useObjectStorage">{{ $t('object-storage-endpoint') }}</ui-input> - <ui-horizon-group inputs> - <ui-input v-model="objectStorageRegion" :disabled="!useObjectStorage">{{ $t('object-storage-region') }}</ui-input> - <ui-input v-model="objectStoragePort" type="number" :disabled="!useObjectStorage">{{ $t('object-storage-port') }}</ui-input> - </ui-horizon-group> - <ui-horizon-group inputs> - <ui-input v-model="objectStorageAccessKey" :disabled="!useObjectStorage"><template #icon><fa icon="key"/></template>{{ $t('object-storage-access-key') }}</ui-input> - <ui-input v-model="objectStorageSecretKey" :disabled="!useObjectStorage"><template #icon><fa icon="key"/></template>{{ $t('object-storage-secret-key') }}</ui-input> - </ui-horizon-group> - <ui-switch v-model="objectStorageUseSSL" :disabled="!useObjectStorage">{{ $t('object-storage-use-ssl') }}</ui-switch> - </template> - </section> - <section> - <ui-switch v-model="cacheRemoteFiles">{{ $t('cache-remote-files') }}<template #desc>{{ $t('cache-remote-files-desc') }}</template></ui-switch> - <ui-switch v-model="proxyRemoteFiles">{{ $t('proxy-remote-files') }}<template #desc>{{ $t('proxy-remote-files-desc') }}</template></ui-switch> - </section> - <section class="fit-top fit-bottom"> - <ui-input v-model="localDriveCapacityMb" type="number">{{ $t('local-drive-capacity-mb') }}<template #suffix>MB</template><template #desc>{{ $t('mb') }}</template></ui-input> - <ui-input v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles">{{ $t('remote-drive-capacity-mb') }}<template #suffix>MB</template><template #desc>{{ $t('mb') }}</template></ui-input> - </section> - <section> - <ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button> - </section> - </ui-card> - - <ui-card> - <template #title><fa :icon="faThumbtack"/> {{ $t('pinned-users') }}</template> - <section class="fit-top"> - <ui-textarea v-model="pinnedUsers"> - <template #desc>{{ $t('pinned-users-info') }}</template> - </ui-textarea> - <ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button> - </section> - </ui-card> - - <ui-card> - <template #title><fa :icon="faGhost"/> {{ $t('proxy-account-config') }}</template> - <section> - <ui-info>{{ $t('proxy-account-info') }}</ui-info> - <ui-input v-model="proxyAccount"><template #prefix>@</template>{{ $t('proxy-account-username') }}<template #desc>{{ $t('proxy-account-username-desc') }}</template></ui-input> - <ui-info warn>{{ $t('proxy-account-warn') }}</ui-info> - </section> - <section> - <ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button> - </section> - </ui-card> - - <ui-card> - <template #title><fa :icon="farEnvelope"/> {{ $t('email-config') }}</template> - <section> - <ui-switch v-model="enableEmail">{{ $t('enable-email') }}<template #desc>{{ $t('email-config-info') }}</template></ui-switch> - <template v-if="enableEmail"> - <ui-input v-model="email" type="email" :disabled="!enableEmail">{{ $t('email') }}</ui-input> - <ui-horizon-group inputs> - <ui-input v-model="smtpHost" :disabled="!enableEmail">{{ $t('smtp-host') }}</ui-input> - <ui-input v-model="smtpPort" type="number" :disabled="!enableEmail">{{ $t('smtp-port') }}</ui-input> - </ui-horizon-group> - <ui-switch v-model="smtpAuth">{{ $t('smtp-auth') }}</ui-switch> - <ui-horizon-group inputs> - <ui-input v-model="smtpUser" :disabled="!enableEmail || !smtpAuth">{{ $t('smtp-user') }}</ui-input> - <ui-input v-model="smtpPass" type="password" :with-password-toggle="true" :disabled="!enableEmail || !smtpAuth">{{ $t('smtp-pass') }}</ui-input> - </ui-horizon-group> - <ui-switch v-model="smtpSecure" :disabled="!enableEmail">{{ $t('smtp-secure') }}<template #desc>{{ $t('smtp-secure-info') }}</template></ui-switch> - <ui-button @click="testEmail()">{{ $t('test-email') }}</ui-button> - </template> - </section> - <section> - <ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button> - </section> - </ui-card> - - <ui-card> - <template #title><fa :icon="faBolt"/> {{ $t('serviceworker-config') }}</template> - <section> - <ui-switch v-model="enableServiceWorker">{{ $t('enable-serviceworker') }}<template #desc>{{ $t('serviceworker-info') }}</template></ui-switch> - <template v-if="enableServiceWorker"> - <ui-info>{{ $t('vapid-info') }}<br><code>npm i web-push -g<br>web-push generate-vapid-keys</code></ui-info> - <ui-horizon-group inputs class="fit-bottom"> - <ui-input v-model="swPublicKey" :disabled="!enableServiceWorker"><template #icon><fa icon="key"/></template>{{ $t('vapid-publickey') }}</ui-input> - <ui-input v-model="swPrivateKey" :disabled="!enableServiceWorker"><template #icon><fa icon="key"/></template>{{ $t('vapid-privatekey') }}</ui-input> - </ui-horizon-group> - </template> - </section> - <section> - <ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button> - </section> - </ui-card> - - <ui-card> - <template #title><fa :icon="faShieldAlt"/> {{ $t('recaptcha-config') }}</template> - <section :class="enableRecaptcha ? 'fit-bottom' : ''"> - <ui-switch v-model="enableRecaptcha">{{ $t('enable-recaptcha') }}</ui-switch> - <template v-if="enableRecaptcha"> - <ui-info>{{ $t('recaptcha-info') }}</ui-info> - <ui-info warn>{{ $t('recaptcha-info2') }}</ui-info> - <ui-horizon-group inputs> - <ui-input v-model="recaptchaSiteKey" :disabled="!enableRecaptcha"><template #icon><fa icon="key"/></template>{{ $t('recaptcha-site-key') }}</ui-input> - <ui-input v-model="recaptchaSecretKey" :disabled="!enableRecaptcha"><template #icon><fa icon="key"/></template>{{ $t('recaptcha-secret-key') }}</ui-input> - </ui-horizon-group> - </template> - </section> - <section v-if="enableRecaptcha && recaptchaSiteKey"> - <header>{{ $t('recaptcha-preview') }}</header> - <div ref="recaptcha" style="margin: 16px 0 0 0;" :key="recaptchaSiteKey"></div> - </section> - <section> - <ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button> - </section> - </ui-card> - - <ui-card> - <template #title><fa :icon="faShieldAlt"/> {{ $t('external-service-integration-config') }}</template> - <section> - <header><fa :icon="['fab', 'twitter']"/> {{ $t('twitter-integration-config') }}</header> - <ui-switch v-model="enableTwitterIntegration">{{ $t('enable-twitter-integration') }}</ui-switch> - <template v-if="enableTwitterIntegration"> - <ui-horizon-group> - <ui-input v-model="twitterConsumerKey" :disabled="!enableTwitterIntegration"><template #icon><fa icon="key"/></template>{{ $t('twitter-integration-consumer-key') }}</ui-input> - <ui-input v-model="twitterConsumerSecret" :disabled="!enableTwitterIntegration"><template #icon><fa icon="key"/></template>{{ $t('twitter-integration-consumer-secret') }}</ui-input> - </ui-horizon-group> - <ui-info>{{ $t('twitter-integration-info', { url: `${url}/api/tw/cb` }) }}</ui-info> - </template> - </section> - <section> - <header><fa :icon="['fab', 'github']"/> {{ $t('github-integration-config') }}</header> - <ui-switch v-model="enableGithubIntegration">{{ $t('enable-github-integration') }}</ui-switch> - <template v-if="enableGithubIntegration"> - <ui-horizon-group> - <ui-input v-model="githubClientId" :disabled="!enableGithubIntegration"><template #icon><fa icon="key"/></template>{{ $t('github-integration-client-id') }}</ui-input> - <ui-input v-model="githubClientSecret" :disabled="!enableGithubIntegration"><template #icon><fa icon="key"/></template>{{ $t('github-integration-client-secret') }}</ui-input> - </ui-horizon-group> - <ui-info>{{ $t('github-integration-info', { url: `${url}/api/gh/cb` }) }}</ui-info> - </template> - </section> - <section> - <header><fa :icon="['fab', 'discord']"/> {{ $t('discord-integration-config') }}</header> - <ui-switch v-model="enableDiscordIntegration">{{ $t('enable-discord-integration') }}</ui-switch> - <template v-if="enableDiscordIntegration"> - <ui-horizon-group> - <ui-input v-model="discordClientId" :disabled="!enableDiscordIntegration"><template #icon><fa icon="key"/></template>{{ $t('discord-integration-client-id') }}</ui-input> - <ui-input v-model="discordClientSecret" :disabled="!enableDiscordIntegration"><template #icon><fa icon="key"/></template>{{ $t('discord-integration-client-secret') }}</ui-input> - </ui-horizon-group> - <ui-info>{{ $t('discord-integration-info', { url: `${url}/api/dc/cb` }) }}</ui-info> - </template> - </section> - <section> - <ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button> - </section> - </ui-card> - - <details> - <summary style="color:var(--text);">{{ $t('advanced-config') }}</summary> - - <ui-card> - <template #title><fa :icon="faHashtag"/> {{ $t('hidden-tags') }}</template> - <section class="fit-top"> - <ui-textarea v-model="hiddenTags"> - <template #desc>{{ $t('hidden-tags-info') }}</template> - </ui-textarea> - <ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button> - </section> - </ui-card> - - <ui-card> - <template #title>summaly Proxy</template> - <section class="fit-top fit-bottom"> - <ui-input v-model="summalyProxy">URL</ui-input> - </section> - <section> - <ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button> - </section> - </ui-card> - </details> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -import { url, host } from '../../config'; -import { toUnicode } from 'punycode'; -import { faHeadset, faShieldAlt, faGhost, faUserPlus, faBolt, faThumbtack, faPencilAlt, faHashtag } from '@fortawesome/free-solid-svg-icons'; -import { faEnvelope as farEnvelope, faSave } from '@fortawesome/free-regular-svg-icons'; - -export default Vue.extend({ - i18n: i18n('admin/views/instance.vue'), - - data() { - return { - url, - host: toUnicode(host), - maintainerName: null, - maintainerEmail: null, - ToSUrl: null, - repositoryUrl: "https://github.com/syuilo/misskey", - feedbackUrl: null, - disableRegistration: false, - disableLocalTimeline: false, - disableGlobalTimeline: false, - enableEmojiReaction: true, - useStarForReactionFallback: false, - mascotImageUrl: null, - bannerUrl: null, - errorImageUrl: null, - iconUrl: null, - name: null, - description: null, - languages: null, - cacheRemoteFiles: false, - proxyRemoteFiles: false, - localDriveCapacityMb: null, - remoteDriveCapacityMb: null, - maxNoteTextLength: null, - enableRecaptcha: false, - recaptchaSiteKey: null, - recaptchaSecretKey: null, - enableTwitterIntegration: false, - twitterConsumerKey: null, - twitterConsumerSecret: null, - enableGithubIntegration: false, - githubClientId: null, - githubClientSecret: null, - enableDiscordIntegration: false, - discordClientId: null, - discordClientSecret: null, - proxyAccount: null, - summalyProxy: null, - enableEmail: false, - email: null, - smtpSecure: false, - smtpHost: null, - smtpPort: null, - smtpUser: null, - smtpPass: null, - smtpAuth: false, - enableServiceWorker: false, - swPublicKey: null, - swPrivateKey: null, - pinnedUsers: '', - hiddenTags: '', - useObjectStorage: false, - objectStorageBaseUrl: null, - objectStorageBucket: null, - objectStoragePrefix: null, - objectStorageEndpoint: null, - objectStorageRegion: null, - objectStoragePort: null, - objectStorageAccessKey: null, - objectStorageSecretKey: null, - objectStorageUseSSL: false, - faHeadset, faShieldAlt, faGhost, faUserPlus, farEnvelope, faBolt, faThumbtack, faPencilAlt, faSave, faHashtag - }; - }, - - created() { - this.$root.getMeta(true).then(meta => { - this.maintainerName = meta.maintainerName; - this.maintainerEmail = meta.maintainerEmail; - this.ToSUrl = meta.ToSUrl; - this.repositoryUrl = meta.repositoryUrl; - this.feedbackUrl = meta.feedbackUrl; - this.disableRegistration = meta.disableRegistration; - this.disableLocalTimeline = meta.disableLocalTimeline; - this.disableGlobalTimeline = meta.disableGlobalTimeline; - this.enableEmojiReaction = meta.enableEmojiReaction; - this.useStarForReactionFallback = meta.useStarForReactionFallback; - this.mascotImageUrl = meta.mascotImageUrl; - this.bannerUrl = meta.bannerUrl; - this.errorImageUrl = meta.errorImageUrl; - this.iconUrl = meta.iconUrl; - this.name = meta.name; - this.description = meta.description; - this.languages = meta.langs.join(' '); - this.cacheRemoteFiles = meta.cacheRemoteFiles; - this.proxyRemoteFiles = meta.proxyRemoteFiles; - this.localDriveCapacityMb = meta.driveCapacityPerLocalUserMb; - this.remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb; - this.maxNoteTextLength = meta.maxNoteTextLength; - this.enableRecaptcha = meta.enableRecaptcha; - this.recaptchaSiteKey = meta.recaptchaSiteKey; - this.recaptchaSecretKey = meta.recaptchaSecretKey; - this.proxyAccount = meta.proxyAccount; - this.enableTwitterIntegration = meta.enableTwitterIntegration; - this.twitterConsumerKey = meta.twitterConsumerKey; - this.twitterConsumerSecret = meta.twitterConsumerSecret; - this.enableGithubIntegration = meta.enableGithubIntegration; - this.githubClientId = meta.githubClientId; - this.githubClientSecret = meta.githubClientSecret; - this.enableDiscordIntegration = meta.enableDiscordIntegration; - this.discordClientId = meta.discordClientId; - this.discordClientSecret = meta.discordClientSecret; - this.summalyProxy = meta.summalyProxy; - this.enableEmail = meta.enableEmail; - this.email = meta.email; - this.smtpSecure = meta.smtpSecure; - this.smtpHost = meta.smtpHost; - this.smtpPort = meta.smtpPort; - this.smtpUser = meta.smtpUser; - this.smtpPass = meta.smtpPass; - this.smtpAuth = meta.smtpUser != null && meta.smtpUser !== ''; - this.enableServiceWorker = meta.enableServiceWorker; - this.swPublicKey = meta.swPublickey; - this.swPrivateKey = meta.swPrivateKey; - this.pinnedUsers = meta.pinnedUsers.join('\n'); - this.hiddenTags = meta.hiddenTags.join('\n'); - this.useObjectStorage = meta.useObjectStorage; - this.objectStorageBaseUrl = meta.objectStorageBaseUrl; - this.objectStorageBucket = meta.objectStorageBucket; - this.objectStoragePrefix = meta.objectStoragePrefix; - this.objectStorageEndpoint = meta.objectStorageEndpoint; - this.objectStorageRegion = meta.objectStorageRegion; - this.objectStoragePort = meta.objectStoragePort; - this.objectStorageAccessKey = meta.objectStorageAccessKey; - this.objectStorageSecretKey = meta.objectStorageSecretKey; - this.objectStorageUseSSL = meta.objectStorageUseSSL; - }); - }, - - mounted() { - const renderRecaptchaPreview = () => { - if (!(window as any).grecaptcha) return; - if (!this.$refs.recaptcha) return; - if (!this.recaptchaSiteKey) return; - (window as any).grecaptcha.render(this.$refs.recaptcha, { - sitekey: this.recaptchaSiteKey - }); - }; - - window.onRecaotchaLoad = () => { - renderRecaptchaPreview(); - }; - - const head = document.getElementsByTagName('head')[0]; - const script = document.createElement('script'); - script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?onload=onRecaotchaLoad'); - head.appendChild(script); - - this.$watch('enableRecaptcha', () => { - renderRecaptchaPreview(); - }); - - this.$watch('recaptchaSiteKey', () => { - renderRecaptchaPreview(); - }); - }, - - methods: { - invite() { - this.$root.api('admin/invite').then(x => { - this.$root.dialog({ - type: 'info', - text: x.code - }); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - }, - - async testEmail() { - this.$root.api('admin/send-email', { - to: this.maintainerEmail, - subject: 'Test email', - text: 'Yo' - }).then(x => { - this.$root.dialog({ - type: 'success', - splash: true - }); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - }, - - updateMeta() { - this.$root.api('admin/update-meta', { - maintainerName: this.maintainerName, - maintainerEmail: this.maintainerEmail, - ToSUrl: this.ToSUrl, - repositoryUrl: this.repositoryUrl, - feedbackUrl: this.feedbackUrl, - disableRegistration: this.disableRegistration, - disableLocalTimeline: this.disableLocalTimeline, - disableGlobalTimeline: this.disableGlobalTimeline, - enableEmojiReaction: this.enableEmojiReaction, - useStarForReactionFallback: this.useStarForReactionFallback, - mascotImageUrl: this.mascotImageUrl, - bannerUrl: this.bannerUrl, - errorImageUrl: this.errorImageUrl, - iconUrl: this.iconUrl, - name: this.name, - description: this.description, - langs: this.languages ? this.languages.split(' ') : [], - cacheRemoteFiles: this.cacheRemoteFiles, - proxyRemoteFiles: this.proxyRemoteFiles, - localDriveCapacityMb: parseInt(this.localDriveCapacityMb, 10), - remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10), - maxNoteTextLength: parseInt(this.maxNoteTextLength, 10), - enableRecaptcha: this.enableRecaptcha, - recaptchaSiteKey: this.recaptchaSiteKey, - recaptchaSecretKey: this.recaptchaSecretKey, - proxyAccount: this.proxyAccount, - enableTwitterIntegration: this.enableTwitterIntegration, - twitterConsumerKey: this.twitterConsumerKey, - twitterConsumerSecret: this.twitterConsumerSecret, - enableGithubIntegration: this.enableGithubIntegration, - githubClientId: this.githubClientId, - githubClientSecret: this.githubClientSecret, - enableDiscordIntegration: this.enableDiscordIntegration, - discordClientId: this.discordClientId, - discordClientSecret: this.discordClientSecret, - summalyProxy: this.summalyProxy, - enableEmail: this.enableEmail, - email: this.email, - smtpSecure: this.smtpSecure, - smtpHost: this.smtpHost, - smtpPort: parseInt(this.smtpPort, 10), - smtpUser: this.smtpAuth ? this.smtpUser : '', - smtpPass: this.smtpAuth ? this.smtpPass : '', - enableServiceWorker: this.enableServiceWorker, - swPublicKey: this.swPublicKey, - swPrivateKey: this.swPrivateKey, - pinnedUsers: this.pinnedUsers ? this.pinnedUsers.split('\n') : [], - hiddenTags: this.hiddenTags ? this.hiddenTags.split('\n') : [], - useObjectStorage: this.useObjectStorage, - objectStorageBaseUrl: this.objectStorageBaseUrl ? this.objectStorageBaseUrl : null, - objectStorageBucket: this.objectStorageBucket ? this.objectStorageBucket : null, - objectStoragePrefix: this.objectStoragePrefix ? this.objectStoragePrefix : null, - objectStorageEndpoint: this.objectStorageEndpoint ? this.objectStorageEndpoint : null, - objectStorageRegion: this.objectStorageRegion ? this.objectStorageRegion : null, - objectStoragePort: this.objectStoragePort ? this.objectStoragePort : null, - objectStorageAccessKey: this.objectStorageAccessKey ? this.objectStorageAccessKey : null, - objectStorageSecretKey: this.objectStorageSecretKey ? this.objectStorageSecretKey : null, - objectStorageUseSSL: this.objectStorageUseSSL, - }).then(() => { - this.$root.dialog({ - type: 'success', - text: this.$t('saved') - }); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - } - } -}); -</script> diff --git a/src/client/app/admin/views/logs.vue b/src/client/app/admin/views/logs.vue deleted file mode 100644 index cb54318187..0000000000 --- a/src/client/app/admin/views/logs.vue +++ /dev/null @@ -1,119 +0,0 @@ -<template> -<div> - <ui-card> - <template #title><fa :icon="faStream"/> {{ $t('logs') }}</template> - <section class="fit-top"> - <ui-horizon-group inputs> - <ui-input v-model="domain" :debounce="true"> - <span>{{ $t('domain') }}</span> - </ui-input> - <ui-select v-model="level"> - <template #label>{{ $t('level') }}</template> - <option value="all">{{ $t('levels.all') }}</option> - <option value="info">{{ $t('levels.info') }}</option> - <option value="success">{{ $t('levels.success') }}</option> - <option value="warning">{{ $t('levels.warning') }}</option> - <option value="error">{{ $t('levels.error') }}</option> - <option value="debug">{{ $t('levels.debug') }}</option> - </ui-select> - </ui-horizon-group> - - <div class="nqjzuvev"> - <code v-for="log in logs" :key="log.id" :class="log.level"> - <details> - <summary><mk-time :time="log.createdAt"/> [{{ log.domain.join('.') }}] {{ log.message }}</summary> - <vue-json-pretty v-if="log.data" :data="log.data"></vue-json-pretty> - </details> - </code> - </div> - - <ui-button @click="deleteAll()">{{ $t('delete-all') }}</ui-button> - </section> - </ui-card> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -import { faStream } from '@fortawesome/free-solid-svg-icons'; -import VueJsonPretty from 'vue-json-pretty'; - -export default Vue.extend({ - i18n: i18n('admin/views/logs.vue'), - - components: { - VueJsonPretty - }, - - data() { - return { - logs: [], - level: 'all', - domain: '', - faStream - }; - }, - - watch: { - level() { - this.logs = []; - this.fetch(); - }, - - domain() { - this.logs = []; - this.fetch(); - } - }, - - mounted() { - this.fetch(); - }, - - methods: { - fetch() { - this.$root.api('admin/logs', { - level: this.level === 'all' ? null : this.level, - domain: this.domain === '' ? null : this.domain, - limit: 100 - }).then(logs => { - this.logs = logs.reverse(); - }); - }, - - deleteAll() { - this.$root.api('admin/delete-logs').then(() => { - this.$root.dialog({ - type: 'success', - splash: true - }); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.nqjzuvev - padding 8px - background #000 - color #fff - font-size 14px - - > code - display block - - &.error - color #f00 - - &.warning - color #ff0 - - &.success - color #0f0 - - &.debug - opacity 0.7 - -</style> diff --git a/src/client/app/admin/views/moderators.vue b/src/client/app/admin/views/moderators.vue deleted file mode 100644 index 8ceab02d97..0000000000 --- a/src/client/app/admin/views/moderators.vue +++ /dev/null @@ -1,127 +0,0 @@ -<template> -<div> - <ui-card> - <template #title><fa icon="plus"/> {{ $t('add-moderator.title') }}</template> - <section class="fit-top"> - <ui-input v-model="username" type="text"> - <template #prefix>@</template> - </ui-input> - <ui-horizon-group> - <ui-button @click="add" :disabled="changing">{{ $t('add-moderator.add') }}</ui-button> - <ui-button @click="remove" :disabled="changing">{{ $t('add-moderator.remove') }}</ui-button> - </ui-horizon-group> - </section> - </ui-card> - - <ui-card> - <template #title>{{ $t('logs.title') }}</template> - <section class="fit-top"> - <sequential-entrance animation="entranceFromTop" delay="25"> - <div v-for="log in logs" :key="log.id" class=""> - <ui-horizon-group inputs> - <ui-input :value="log.user | acct" type="text" readonly> - <span>{{ $t('logs.moderator') }}</span> - </ui-input> - <ui-input :value="log.type" type="text" readonly> - <span>{{ $t('logs.type') }}</span> - </ui-input> - <ui-input :value="log.createdAt | date" type="text" readonly> - <span>{{ $t('logs.at') }}</span> - </ui-input> - </ui-horizon-group> - <ui-textarea :value="JSON.stringify(log.info, null, 4)" readonly> - <span>{{ $t('logs.info') }}</span> - </ui-textarea> - </div> - </sequential-entrance> - <ui-button v-if="existMoreLogs" @click="fetchLogs">{{ $t('@.load-more') }}</ui-button> - </section> - </ui-card> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -import parseAcct from "../../../../misc/acct/parse"; - -export default Vue.extend({ - i18n: i18n('admin/views/moderators.vue'), - - data() { - return { - username: '', - changing: false, - logs: [], - untilLogId: null, - existMoreLogs: false - }; - }, - - created() { - this.fetchLogs(); - }, - - methods: { - async add() { - this.changing = true; - - const process = async () => { - const user = await this.$root.api('users/show', parseAcct(this.username)); - await this.$root.api('admin/moderators/add', { userId: user.id }); - this.$root.dialog({ - type: 'success', - text: this.$t('add-moderator.added') - }); - }; - - await process().catch(e => { - this.$root.dialog({ - type: 'error', - text: e.toString() - }); - }); - - this.changing = false; - }, - - async remove() { - this.changing = true; - - const process = async () => { - const user = await this.$root.api('users/show', parseAcct(this.username)); - await this.$root.api('admin/moderators/remove', { userId: user.id }); - this.$root.dialog({ - type: 'success', - text: this.$t('add-moderator.removed') - }); - }; - - await process().catch(e => { - this.$root.dialog({ - type: 'error', - text: e.toString() - }); - }); - - this.changing = false; - }, - - fetchLogs() { - this.$root.api('admin/show-moderation-logs', { - untilId: this.untilId, - limit: 10 + 1 - }).then(logs => { - if (logs.length == 10 + 1) { - logs.pop(); - this.existMoreLogs = true; - } else { - this.existMoreLogs = false; - } - this.logs = this.logs.concat(logs); - this.untilLogId = this.logs[this.logs.length - 1].id; - }); - }, - } -}); -</script> diff --git a/src/client/app/admin/views/queue.chart.vue b/src/client/app/admin/views/queue.chart.vue deleted file mode 100644 index ff29aa8392..0000000000 --- a/src/client/app/admin/views/queue.chart.vue +++ /dev/null @@ -1,181 +0,0 @@ -<template> -<div> - <ui-info warn v-if="latestStats && latestStats.waiting > 0">The queue is jammed.</ui-info> - <ui-horizon-group inputs v-if="latestStats" class="fit-bottom"> - <ui-input :value="latestStats.activeSincePrevTick | number" type="text" readonly> - <span>Process</span> - <template #prefix><fa :icon="fasPlayCircle"/></template> - <template #suffix>jobs/tick</template> - </ui-input> - <ui-input :value="latestStats.active | number" type="text" readonly> - <span>Active</span> - <template #prefix><fa :icon="farPlayCircle"/></template> - <template #suffix>jobs</template> - </ui-input> - <ui-input :value="latestStats.waiting | number" type="text" readonly> - <span>Waiting</span> - <template #prefix><fa :icon="faStopCircle"/></template> - <template #suffix>jobs</template> - </ui-input> - <ui-input :value="latestStats.delayed | number" type="text" readonly> - <span>Delayed</span> - <template #prefix><fa :icon="faStopwatch"/></template> - <template #suffix>jobs</template> - </ui-input> - </ui-horizon-group> - <div ref="chart" class="wptihjuy"></div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -import ApexCharts from 'apexcharts'; -import * as tinycolor from 'tinycolor2'; -import { faStopwatch, faPlayCircle as fasPlayCircle } from '@fortawesome/free-solid-svg-icons'; -import { faStopCircle, faPlayCircle as farPlayCircle } from '@fortawesome/free-regular-svg-icons'; - -export default Vue.extend({ - i18n: i18n('admin/views/queue.vue'), - - props: { - type: { - type: String, - required: true - }, - connection: { - required: true - }, - limit: { - type: Number, - required: true - } - }, - - data() { - return { - stats: [], - chart: null, - faStopwatch, faStopCircle, farPlayCircle, fasPlayCircle - }; - }, - - computed: { - latestStats(): any { - return this.stats.length > 0 ? this.stats[this.stats.length - 1][this.type] : null; - } - }, - - watch: { - stats(stats) { - this.chart.updateSeries([{ - name: 'Process', - type: 'area', - data: stats.map((x, i) => ({ x: i, y: x[this.type].activeSincePrevTick })) - }, { - name: 'Active', - type: 'area', - data: stats.map((x, i) => ({ x: i, y: x[this.type].active })) - }, { - name: 'Waiting', - type: 'line', - data: stats.map((x, i) => ({ x: i, y: x[this.type].waiting })) - }, { - name: 'Delayed', - type: 'line', - data: stats.map((x, i) => ({ x: i, y: x[this.type].delayed })) - }]); - }, - }, - - mounted() { - this.chart = new ApexCharts(this.$refs.chart, { - chart: { - id: this.type, - group: 'queue', - type: 'area', - height: 200, - animations: { - dynamicAnimation: { - enabled: false - } - }, - toolbar: { - show: false - }, - zoom: { - enabled: false - } - }, - dataLabels: { - enabled: false - }, - grid: { - clipMarkers: false, - borderColor: 'rgba(0, 0, 0, 0.1)', - xaxis: { - lines: { - show: true, - } - }, - }, - stroke: { - curve: 'straight', - width: 2 - }, - tooltip: { - enabled: false - }, - legend: { - labels: { - colors: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString() - }, - }, - series: [] as any, - colors: ['#00E396', '#00BCD4', '#FFB300', '#e53935'], - xaxis: { - type: 'numeric', - labels: { - show: false - }, - tooltip: { - enabled: false - } - }, - yaxis: { - show: false, - min: 0, - } - }); - - this.chart.render(); - - this.connection.on('stats', this.onStats); - this.connection.on('statsLog', this.onStatsLog); - - this.$once('hook:beforeDestroy', () => { - if (this.chart) this.chart.destroy(); - }); - }, - - methods: { - onStats(stats) { - this.stats.push(stats); - if (this.stats.length > this.limit) this.stats.shift(); - }, - - onStatsLog(statsLog) { - for (const stats of statsLog.reverse()) { - this.onStats(stats); - } - }, - } -}); -</script> - -<style lang="stylus" scoped> -.wptihjuy - min-height 200px !important - margin -8px - -</style> diff --git a/src/client/app/admin/views/queue.vue b/src/client/app/admin/views/queue.vue deleted file mode 100644 index 9aa740c68c..0000000000 --- a/src/client/app/admin/views/queue.vue +++ /dev/null @@ -1,159 +0,0 @@ -<template> -<div> - <ui-card> - <template #title><fa :icon="faChartBar"/> {{ $t('title') }}</template> - <section> - <header><fa :icon="faPaperPlane"/> {{ $t('domains.deliver') }}</header> - <x-chart v-if="connection" :connection="connection" :limit="chartLimit" type="deliver"/> - </section> - <section> - <header><fa :icon="faInbox"/> {{ $t('domains.inbox') }}</header> - <x-chart v-if="connection" :connection="connection" :limit="chartLimit" type="inbox"/> - </section> - <section> - <details> - <summary>{{ $t('other-queues') }}</summary> - <section> - <header><fa :icon="faDatabase"/> {{ $t('domains.db') }}</header> - <x-chart v-if="connection" :connection="connection" :limit="chartLimit" type="db"/> - </section> - <ui-hr/> - <section> - <header><fa :icon="faCloud"/> {{ $t('domains.objectStorage') }}</header> - <x-chart v-if="connection" :connection="connection" :limit="chartLimit" type="objectStorage"/> - </section> - </details> - </section> - <section> - <ui-button @click="removeAllJobs">{{ $t('remove-all-jobs') }}</ui-button> - </section> - </ui-card> - - <ui-card> - <template #title><fa :icon="faTasks"/> {{ $t('jobs') }}</template> - <section class="fit-top"> - <ui-horizon-group inputs> - <ui-select v-model="domain"> - <template #label>{{ $t('queue') }}</template> - <option value="deliver">{{ $t('domains.deliver') }}</option> - <option value="inbox">{{ $t('domains.inbox') }}</option> - <option value="db">{{ $t('domains.db') }}</option> - <option value="objectStorage">{{ $t('domains.objectStorage') }}</option> - </ui-select> - <ui-select v-model="state"> - <template #label>{{ $t('state') }}</template> - <option value="active">{{ $t('states.active') }}</option> - <option value="waiting">{{ $t('states.waiting') }}</option> - <option value="delayed">{{ $t('states.delayed') }}</option> - </ui-select> - </ui-horizon-group> - <sequential-entrance animation="entranceFromTop" delay="25"> - <div class="xvvuvgsv" v-for="job in jobs" :key="job.id"> - <b>{{ job.id }}</b> - <template v-if="domain === 'deliver'"> - <span>{{ job.data.to }}</span> - </template> - <template v-if="domain === 'inbox'"> - <span>{{ job.data.activity.id }}</span> - </template> - <span>{{ `(${job.attempts}/${job.maxAttempts}, ${Math.floor((jobsFetched - job.timestamp) / 1000 / 60)}min)` }}</span> - </div> - </sequential-entrance> - <ui-info v-if="jobs.length == jobsLimit">{{ $t('result-is-truncated', { n: jobsLimit }) }}</ui-info> - </section> - </ui-card> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faTasks, faInbox, faDatabase, faCloud } from '@fortawesome/free-solid-svg-icons'; -import { faPaperPlane, faChartBar } from '@fortawesome/free-regular-svg-icons'; -import i18n from '../../i18n'; -import XChart from './queue.chart.vue'; - -export default Vue.extend({ - i18n: i18n('admin/views/queue.vue'), - - components: { - XChart - }, - - data() { - return { - connection: null, - chartLimit: 200, - jobs: [], - jobsLimit: 50, - jobsFetched: Date.now(), - domain: 'deliver', - state: 'delayed', - faTasks, faPaperPlane, faInbox, faChartBar, faDatabase, faCloud - }; - }, - - watch: { - domain() { - this.jobs = []; - this.fetchJobs(); - }, - - state() { - this.jobs = []; - this.fetchJobs(); - }, - }, - - mounted() { - this.fetchJobs(); - - this.connection = this.$root.stream.useSharedConnection('queueStats'); - this.connection.send('requestLog', { - id: Math.random().toString().substr(2, 8), - length: this.chartLimit - }); - - this.$once('hook:beforeDestroy', () => { - this.connection.dispose(); - }); - }, - - methods: { - async removeAllJobs() { - const process = async () => { - await this.$root.api('admin/queue/clear'); - this.$root.dialog({ - type: 'success', - splash: true - }); - }; - - await process().catch(e => { - this.$root.dialog({ - type: 'error', - text: e.toString() - }); - }); - }, - - fetchJobs() { - this.$root.api('admin/queue/jobs', { - domain: this.domain, - state: this.state, - limit: this.jobsLimit - }).then(jobs => { - this.jobsFetched = Date.now(), - this.jobs = jobs; - }); - }, - } -}); -</script> - -<style lang="stylus" scoped> -.xvvuvgsv - margin-left -6px - > b, span - margin 0 6px - -</style> diff --git a/src/client/app/admin/views/users.user.vue b/src/client/app/admin/views/users.user.vue deleted file mode 100644 index 9c3db2d6c2..0000000000 --- a/src/client/app/admin/views/users.user.vue +++ /dev/null @@ -1,95 +0,0 @@ -<template> -<div class="kofvwchc"> - <div> - <a :href="user | userPage(null, true)"> - <mk-avatar class="avatar" :user="user" :disable-link="true"/> - </a> - </div> - <div @click="click(user.id)"> - <header> - <b><mk-user-name :user="user"/></b> - <span class="username">@{{ user | acct }}</span> - <span class="is-admin" v-if="user.isAdmin">admin</span> - <span class="is-moderator" v-if="user.isModerator">moderator</span> - <span class="is-silenced" v-if="user.isSilenced" :title="$t('@.silenced-user')"><fa :icon="faMicrophoneSlash"/></span> - <span class="is-suspended" v-if="user.isSuspended" :title="$t('@.suspended-user')"><fa :icon="faSnowflake"/></span> - </header> - <div> - <span>{{ $t('users.updatedAt') }}: <mk-time :time="user.updatedAt" mode="detail"/></span> - </div> - <div> - <span>{{ $t('users.createdAt') }}: <mk-time :time="user.createdAt" mode="detail"/></span> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -import { faMicrophoneSlash } from '@fortawesome/free-solid-svg-icons'; -import { faSnowflake } from '@fortawesome/free-regular-svg-icons'; - -export default Vue.extend({ - i18n: i18n('admin/views/users.vue'), - props: ['user', 'click'], - data() { - return { - faSnowflake, faMicrophoneSlash - }; - }, -}); -</script> - -<style lang="stylus" scoped> -.kofvwchc - display flex - padding 16px - border-top solid 1px var(--faceDivider) - - > div:first-child - > a - > .avatar - width 64px - height 64px - - > div:last-child - flex 1 - cursor pointer - padding-left 16px - - @media (max-width 500px) - font-size 14px - - > header - > .username - margin-left 8px - opacity 0.7 - - > .is-admin - > .is-moderator - flex-shrink 0 - align-self center - margin 0 0 0 .5em - padding 1px 6px - font-size 80% - border-radius 3px - background var(--noteHeaderAdminBg) - color var(--noteHeaderAdminFg) - - > .is-silenced - > .is-suspended - margin 0 0 0 .5em - color #4dabf7 - - &:hover - color var(--primaryForeground) - background var(--primary) - text-decoration none - border-radius 3px - - &:active - color var(--primaryForeground) - background var(--primaryDarken10) - border-radius 3px -</style> diff --git a/src/client/app/admin/views/users.vue b/src/client/app/admin/views/users.vue deleted file mode 100644 index 920bfc381e..0000000000 --- a/src/client/app/admin/views/users.vue +++ /dev/null @@ -1,366 +0,0 @@ -<template> -<div> - <ui-card> - <template #title><fa :icon="faTerminal"/> {{ $t('operation') }}</template> - <section class="fit-top"> - <ui-input class="target" v-model="target" type="text" @enter="showUser"> - <span>{{ $t('username-or-userid') }}</span> - </ui-input> - <ui-button @click="showUser"><fa :icon="faSearch"/> {{ $t('lookup') }}</ui-button> - - <div ref="user" class="user" v-if="user" :key="user.id"> - <x-user :user="user"/> - <div class="actions"> - <ui-button v-if="user.host != null" @click="updateRemoteUser"><fa :icon="faSync"/> {{ $t('update-remote-user') }}</ui-button> - <ui-button @click="resetPassword"><fa :icon="faKey"/> {{ $t('reset-password') }}</ui-button> - <ui-horizon-group> - <ui-button @click="silenceUser"><fa :icon="faMicrophoneSlash"/> {{ $t('make-silence') }}</ui-button> - <ui-button @click="unsilenceUser">{{ $t('unmake-silence') }}</ui-button> - </ui-horizon-group> - <ui-horizon-group> - <ui-button @click="suspendUser" :disabled="suspending"><fa :icon="faSnowflake"/> {{ $t('suspend') }}</ui-button> - <ui-button @click="unsuspendUser" :disabled="unsuspending">{{ $t('unsuspend') }}</ui-button> - </ui-horizon-group> - <ui-button @click="deleteAllFiles"><fa :icon="faTrashAlt"/> {{ $t('delete-all-files') }}</ui-button> - <ui-textarea v-if="user" :value="user | json5" readonly tall style="margin-top:16px;"></ui-textarea> - </div> - </div> - </section> - </ui-card> - - <ui-card> - <template #title><fa :icon="faUsers"/> {{ $t('users.title') }}</template> - <section class="fit-top"> - <ui-horizon-group inputs> - <ui-select v-model="sort"> - <template #label>{{ $t('users.sort.title') }}</template> - <option value="-createdAt">{{ $t('users.sort.createdAtAsc') }}</option> - <option value="+createdAt">{{ $t('users.sort.createdAtDesc') }}</option> - <option value="-updatedAt">{{ $t('users.sort.updatedAtAsc') }}</option> - <option value="+updatedAt">{{ $t('users.sort.updatedAtDesc') }}</option> - </ui-select> - <ui-select v-model="state"> - <template #label>{{ $t('users.state.title') }}</template> - <option value="all">{{ $t('users.state.all') }}</option> - <option value="available">{{ $t('users.state.available') }}</option> - <option value="admin">{{ $t('users.state.admin') }}</option> - <option value="moderator">{{ $t('users.state.moderator') }}</option> - <option value="silenced">{{ $t('users.state.silenced') }}</option> - <option value="suspended">{{ $t('users.state.suspended') }}</option> - </ui-select> - <ui-select v-model="origin"> - <template #label>{{ $t('users.origin.title') }}</template> - <option value="combined">{{ $t('users.origin.combined') }}</option> - <option value="local">{{ $t('users.origin.local') }}</option> - <option value="remote">{{ $t('users.origin.remote') }}</option> - </ui-select> - </ui-horizon-group> - <ui-horizon-group searchboxes> - <ui-input v-model="searchUsername" type="text" spellcheck="false" @input="fetchUsers(true)"> - <span>{{ $t('username') }}</span> - </ui-input> - <ui-input v-model="searchHost" type="text" spellcheck="false" @input="fetchUsers(true)" :disabled="origin === 'local'"> - <span>{{ $t('host') }}</span> - </ui-input> - </ui-horizon-group> - <sequential-entrance animation="entranceFromTop" delay="25"> - <x-user v-for="user in users" :key="user.id" :user='user' :click="showUserOnClick"/> - </sequential-entrance> - <ui-button v-if="existMore" @click="fetchUsers">{{ $t('@.load-more') }}</ui-button> - </section> - </ui-card> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -import parseAcct from "../../../../misc/acct/parse"; -import { faUsers, faTerminal, faSearch, faKey, faSync, faMicrophoneSlash } from '@fortawesome/free-solid-svg-icons'; -import { faSnowflake, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; -import XUser from './users.user.vue'; - -export default Vue.extend({ - i18n: i18n('admin/views/users.vue'), - components: { - XUser - }, - data() { - return { - user: null, - target: null, - suspending: false, - unsuspending: false, - sort: '+createdAt', - state: 'all', - origin: 'local', - searchUsername: '', - searchHost: '', - limit: 10, - offset: 0, - users: [], - existMore: false, - faTerminal, faUsers, faSnowflake, faSearch, faKey, faSync, faMicrophoneSlash, faTrashAlt - }; - }, - - watch: { - sort() { - this.users = []; - this.offset = 0; - this.fetchUsers(); - }, - - state() { - this.users = []; - this.offset = 0; - this.fetchUsers(); - }, - - origin() { - if (this.origin === 'local') this.searchHost = ''; - this.users = []; - this.offset = 0; - this.fetchUsers(); - } - }, - - mounted() { - this.fetchUsers(); - }, - - methods: { - /** テキストエリアのユーザーを解決する */ - fetchUser() { - return new Promise((res) => { - const usernamePromise = this.$root.api('users/show', parseAcct(this.target)); - const idPromise = this.$root.api('users/show', { userId: this.target }); - - let _notFound = false; - const notFound = () => { - if (_notFound) { - this.$root.dialog({ - type: 'error', - text: this.$t('user-not-found') - }); - } else { - _notFound = true; - } - }; - - usernamePromise.then(res).catch(e => { - if (e == 'user not found') { - notFound(); - } - }); - idPromise.then(res).catch(e => { - notFound(); - }); - }); - }, - - /** テキストエリアから処理対象ユーザーを設定する */ - async showUser() { - this.user = null; - const user = await this.fetchUser(); - this.$root.api('admin/show-user', { userId: user.id }).then(info => { - this.user = info; - }); - this.target = ''; - }, - - async showUserOnClick(userId: string) { - this.$root.api('admin/show-user', { userId: userId }).then(info => { - this.user = info; - this.$nextTick(() => { - this.$refs.user.scrollIntoView(); - }); - }); - }, - - /** 処理対象ユーザーの情報を更新する */ - async refreshUser() { - this.$root.api('admin/show-user', { userId: this.user.id }).then(info => { - this.user = info; - }); - }, - - async resetPassword() { - if (!await this.getConfirmed(this.$t('reset-password-confirm'))) return; - - this.$root.api('admin/reset-password', { userId: this.user.id }).then(res => { - this.$root.dialog({ - type: 'success', - text: this.$t('password-updated', { password: res.password }) - }); - }); - }, - - async silenceUser() { - if (!await this.getConfirmed(this.$t('silence-confirm'))) return; - - const process = async () => { - await this.$root.api('admin/silence-user', { userId: this.user.id }); - this.$root.dialog({ - type: 'success', - splash: true - }); - }; - - await process().catch(e => { - this.$root.dialog({ - type: 'error', - text: e.toString() - }); - }); - - this.refreshUser(); - }, - - async unsilenceUser() { - if (!await this.getConfirmed(this.$t('unsilence-confirm'))) return; - - const process = async () => { - await this.$root.api('admin/unsilence-user', { userId: this.user.id }); - this.$root.dialog({ - type: 'success', - splash: true - }); - }; - - await process().catch(e => { - this.$root.dialog({ - type: 'error', - text: e.toString() - }); - }); - - this.refreshUser(); - }, - - async suspendUser() { - if (!await this.getConfirmed(this.$t('suspend-confirm'))) return; - - this.suspending = true; - - const process = async () => { - await this.$root.api('admin/suspend-user', { userId: this.user.id }); - this.$root.dialog({ - type: 'success', - text: this.$t('suspended') - }); - }; - - await process().catch(e => { - this.$root.dialog({ - type: 'error', - text: e.toString() - }); - }); - - this.suspending = false; - - this.refreshUser(); - }, - - async unsuspendUser() { - if (!await this.getConfirmed(this.$t('unsuspend-confirm'))) return; - - this.unsuspending = true; - - const process = async () => { - await this.$root.api('admin/unsuspend-user', { userId: this.user.id }); - this.$root.dialog({ - type: 'success', - text: this.$t('unsuspended') - }); - }; - - await process().catch(e => { - this.$root.dialog({ - type: 'error', - text: e.toString() - }); - }); - - this.unsuspending = false; - - this.refreshUser(); - }, - - async updateRemoteUser() { - this.$root.api('admin/update-remote-user', { userId: this.user.id }).then(res => { - this.$root.dialog({ - type: 'success', - text: this.$t('remote-user-updated') - }); - }); - - this.refreshUser(); - }, - - async deleteAllFiles() { - if (!await this.getConfirmed(this.$t('delete-all-files-confirm'))) return; - - const process = async () => { - await this.$root.api('admin/delete-all-files-of-a-user', { userId: this.user.id }); - this.$root.dialog({ - type: 'success', - splash: true - }); - }; - - await process().catch(e => { - this.$root.dialog({ - type: 'error', - text: e.toString() - }); - }); - }, - - async getConfirmed(text: string): Promise<Boolean> { - const confirm = await this.$root.dialog({ - type: 'warning', - showCancelButton: true, - title: 'confirm', - text, - }); - - return !confirm.canceled; - }, - - fetchUsers(truncate?: boolean) { - if (truncate) this.offset = 0; - this.$root.api('admin/show-users', { - state: this.state, - origin: this.origin, - sort: this.sort, - offset: this.offset, - limit: this.limit + 1, - username: this.searchUsername, - hostname: this.searchHost - }).then(users => { - if (users.length == this.limit + 1) { - users.pop(); - this.existMore = true; - } else { - this.existMore = false; - } - this.users = truncate ? users : this.users.concat(users); - this.offset += this.limit; - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.target - margin-bottom 16px !important - -.user - margin-top 32px - - > .actions - margin-left 80px -</style> diff --git a/src/client/app/animation.styl b/src/client/app/animation.styl deleted file mode 100644 index 6c4d5b8b6f..0000000000 --- a/src/client/app/animation.styl +++ /dev/null @@ -1,47 +0,0 @@ -.zoom-in-top-enter-active, -.zoom-in-top-leave-active { - opacity: 1; - transform: scaleY(1); - transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); - transform-origin: center top; -} -.zoom-in-top-enter, -.zoom-in-top-leave-active { - opacity: 0; - transform: scaleY(0); -} - -.entranceFromTop { - animation-duration: 0.5s; - animation-name: entranceFromTop; -} - -@keyframes entranceFromTop { - from { - opacity: 0; - transform: translateY(-64px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -@keyframes jump { - 0% { transform: translateY(0); } - 25% { transform: translateY(-16px); } - 50% { transform: translateY(0); } - 75% { transform: translateY(-8px); } - 100% { transform: translateY(0); } -} - -@keyframes blink { - 0% { opacity: 1; } - 30% { opacity: 1; } - 90% { opacity: 0; } -} diff --git a/src/client/app/app.styl b/src/client/app/app.styl deleted file mode 100644 index 6389aa0a87..0000000000 --- a/src/client/app/app.styl +++ /dev/null @@ -1,84 +0,0 @@ -@import "../style" -@import "../animation" - -html - &.progress - &, * - cursor progress !important - -html - // iOSのため - overflow auto - -body - overflow-wrap break-word - -#nprogress - pointer-events none - - position absolute - z-index 65536 - - .bar - background var(--primary) - - position fixed - z-index 65537 - top 0 - left 0 - - width 100% - height 2px - - /* Fancy blur effect */ - .peg - display block - position absolute - right 0 - width 100px - height 100% - box-shadow 0 0 10px var(--primary), 0 0 5px var(--primary) - opacity 1 - - transform rotate(3deg) translate(0px, -4px) - -#wait - display block - position fixed - z-index 65537 - top 15px - right 15px - - &:before - content "" - display block - width 18px - height 18px - box-sizing border-box - - border solid 2px transparent - border-top-color var(--primary) - border-left-color var(--primary) - border-radius 50% - - animation progress-spinner 400ms linear infinite - - @keyframes progress-spinner - 0% - transform rotate(0deg) - 100% - transform rotate(360deg) - -code - font-family Consolas, 'Courier New', Courier, Monaco, monospace - -pre - display block - - > code - display block - overflow auto - tab-size 2 - -[data-icon] - display inline-block diff --git a/src/client/app/app.vue b/src/client/app/app.vue deleted file mode 100644 index e639c9f9ac..0000000000 --- a/src/client/app/app.vue +++ /dev/null @@ -1,32 +0,0 @@ -<template> -<router-view id="app" v-hotkey.global="keymap"></router-view> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { url, lang } from './config'; - -export default Vue.extend({ - computed: { - keymap(): any { - return { - 'h|slash': this.help, - 'd': this.dark - }; - } - }, - - methods: { - help() { - window.open(`${url}/docs/${lang}/keyboard-shortcut`, '_blank'); - }, - - dark() { - this.$store.commit('device/set', { - key: 'darkmode', - value: !this.$store.state.device.darkmode - }); - } - } -}); -</script> diff --git a/src/client/app/auth/assets/icon.svg b/src/client/app/auth/assets/icon.svg deleted file mode 100644 index 36f5d3e404..0000000000 --- a/src/client/app/auth/assets/icon.svg +++ /dev/null @@ -1 +0,0 @@ -<?xml version="1.0" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 512 512" width="512" height="512"><defs><clipPath id="_clipPath_P6eAE2OaBltOJ3gHGVajfqsOnfv4xIns"><rect width="512" height="512"/></clipPath></defs><g clip-path="url(#_clipPath_P6eAE2OaBltOJ3gHGVajfqsOnfv4xIns)"><clipPath id="_clipPath_P6q7MZAUp3XpQhVgs2GuAbegX9v4gkom"><rect x="0" y="0" width="512" height="512" transform="matrix(1,0,0,1,0,0)" fill="rgb(255,255,255)"/></clipPath><g clip-path="url(#_clipPath_P6q7MZAUp3XpQhVgs2GuAbegX9v4gkom)"><g id="Group"><g id="g4502"><g id="g5125"><g id="text4489"><path d=" M 190.093 359.243 C 167.923 359.32 148.881 345.963 139.9 330.409 C 135.104 323.615 125.617 321.198 125.482 330.409 L 125.482 372.939 C 125.482 390.026 119.253 404.799 106.794 417.258 C 94.69 429.362 79.917 435.413 62.474 435.413 C 45.387 435.413 30.614 429.362 18.155 417.258 C 6.052 404.799 0 390.026 0 372.939 L 0 139.061 C 0 125.89 3.738 113.965 11.213 103.285 C 19.045 92.25 29.012 84.596 41.116 80.325 C 47.879 77.833 54.999 76.587 62.474 76.587 C 81.697 76.587 97.716 84.062 110.531 99.013 C 117.295 106.489 121.211 110.405 122.279 110.761 C 122.279 110.761 173.043 172.145 174.467 173.213 C 175.891 174.281 180.073 182.446 190.093 182.446 C 200.112 182.446 204.829 174.281 206.253 173.213 C 207.676 172.145 258.44 110.761 258.44 110.761 C 258.796 111.117 262.534 107.201 269.654 99.013 C 282.825 84.062 299.022 76.587 318.245 76.587 C 325.364 76.587 332.484 77.833 339.603 80.325 C 351.707 84.596 361.496 92.25 368.972 103.285 C 376.803 113.965 380.719 125.89 380.719 139.061 L 380.719 372.939 C 380.719 390.026 374.489 404.799 362.03 417.258 C 349.927 429.362 335.154 435.413 317.711 435.413 C 300.624 435.413 285.851 429.362 273.391 417.258 C 261.288 404.799 255.237 390.026 255.237 372.939 L 255.237 330.409 C 254.184 318.802 243.925 326.116 240.285 330.409 C 230.674 348.208 212.262 359.167 190.093 359.243 Z M 457.535 184.448 Q 435.109 184.448 419.09 168.963 Q 403.605 152.944 403.605 130.518 Q 403.605 108.091 419.09 92.606 Q 435.109 76.587 457.535 76.587 Q 479.962 76.587 495.981 92.606 Q 512 108.091 512 130.518 Q 512 152.944 495.981 168.963 Q 479.962 184.448 457.535 184.448 Z M 458.069 195.128 Q 480.496 195.128 495.981 211.147 Q 512 227.166 512 249.592 L 512 381.482 Q 512 403.909 495.981 419.928 Q 480.496 435.413 458.069 435.413 Q 435.643 435.413 419.624 419.928 Q 403.605 403.909 403.605 381.482 L 403.605 249.592 Q 403.605 227.166 419.624 211.147 Q 435.643 195.128 458.069 195.128 Z " fill-rule="evenodd" fill="rgb(157,157,157)"/></g></g></g></g></g></g></svg>
\ No newline at end of file diff --git a/src/client/app/auth/script.ts b/src/client/app/auth/script.ts deleted file mode 100644 index 91bb24b108..0000000000 --- a/src/client/app/auth/script.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Authorize Form - */ - -import VueRouter from 'vue-router'; - -// Style -import './style.styl'; - -import init from '../init'; -import Index from './views/index.vue'; -import NotFound from '../common/views/pages/not-found.vue'; - -/** - * init - */ -init(launch => { - // Init router - const router = new VueRouter({ - mode: 'history', - base: '/auth/', - routes: [ - { path: '/:token', component: Index }, - { path: '*', component: NotFound } - ] - }); - - // Launch the app - launch(router); -}); diff --git a/src/client/app/auth/style.styl b/src/client/app/auth/style.styl deleted file mode 100644 index bd25e1b572..0000000000 --- a/src/client/app/auth/style.styl +++ /dev/null @@ -1,15 +0,0 @@ -@import "../app" -@import "../reset" - -html - background #eee - - @media (max-width 600px) - background #fff - -body - margin 0 - padding 32px 0 - - @media (max-width 600px) - padding 0 diff --git a/src/client/app/auth/views/form.vue b/src/client/app/auth/views/form.vue deleted file mode 100644 index 064dbf3887..0000000000 --- a/src/client/app/auth/views/form.vue +++ /dev/null @@ -1,141 +0,0 @@ -<template> -<div class="form"> - <header> - <h1 v-html="$t('share-access', { name })"></h1> - <img :src="app.iconUrl"/> - </header> - <div class="app"> - <section> - <h2>{{ app.name }}</h2> - <p class="id">{{ app.id }}</p> - <p class="description">{{ app.description }}</p> - </section> - <section> - <h2>{{ $t('permission-ask') }}</h2> - <ul> - <template v-for="p in app.permission"> - <li :key="p">{{ $t(`@.permissions.${p}`) }}</li> - </template> - </ul> - </section> - </div> - <div class="action"> - <button @click="cancel">{{ $t('cancel') }}</button> - <button @click="accept">{{ $t('accept') }}</button> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; - -export default Vue.extend({ - i18n: i18n('auth/views/form.vue'), - props: ['session'], - computed: { - name(): string { - const el = document.createElement('div'); - el.textContent = this.app.name - return el.innerHTML; - }, - app(): any { - return this.session.app; - } - }, - methods: { - cancel() { - this.$root.api('auth/deny', { - token: this.session.token - }).then(() => { - this.$emit('denied'); - }); - }, - - accept() { - this.$root.api('auth/accept', { - token: this.session.token - }).then(() => { - this.$emit('accepted'); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.form - - > header - > h1 - margin 0 - padding 32px 32px 20px 32px - font-size 24px - font-weight normal - color #777 - - i - color #77aeca - - &:before - content '「' - - &:after - content '」' - - b - color #666 - - > img - display block - z-index 1 - width 84px - height 84px - margin 0 auto -38px auto - border solid 5px #fff - border-radius 100% - box-shadow 0 2px 2px rgba(#000, 0.1) - - > .app - padding 44px 16px 0 16px - color #555 - background #eee - box-shadow 0 2px 2px rgba(#000, 0.1) inset - - &:after - content '' - display block - clear both - - > section - float left - width 50% - padding 8px - text-align left - - > h2 - margin 0 - font-size 16px - color #777 - - > .action - padding 16px - - > button - margin 0 8px - padding 0 - - @media (max-width 600px) - > header - > img - box-shadow none - - > .app - box-shadow none - - @media (max-width 500px) - > header - > h1 - font-size 16px - -</style> diff --git a/src/client/app/auth/views/index.vue b/src/client/app/auth/views/index.vue deleted file mode 100644 index ad9b1e4e35..0000000000 --- a/src/client/app/auth/views/index.vue +++ /dev/null @@ -1,153 +0,0 @@ -<template> -<div class="index"> - <main v-if="$store.getters.isSignedIn"> - <p class="fetching" v-if="fetching">{{ $t('loading') }}<mk-ellipsis/></p> - <x-form - class="form" - ref="form" - v-if="state == 'waiting'" - :session="session" - @denied="state = 'denied'" - @accepted="accepted" - /> - <div class="denied" v-if="state == 'denied'"> - <h1>{{ $t('denied') }}</h1> - <p>{{ $t('denied-paragraph') }}</p> - </div> - <div class="accepted" v-if="state == 'accepted'"> - <h1>{{ session.app.isAuthorized ? this.$t('already-authorized') : this.$t('allowed') }}</h1> - <p v-if="session.app.callbackUrl">{{ $t('callback-url') }}<mk-ellipsis/></p> - <p v-if="!session.app.callbackUrl">{{ $t('please-go-back') }}</p> - </div> - <div class="error" v-if="state == 'fetch-session-error'"> - <p>{{ $t('error') }}</p> - </div> - </main> - <main class="signin" v-if="!$store.getters.isSignedIn"> - <h1>{{ $t('sign-in') }}</h1> - <mk-signin/> - </main> - <footer><img src="/assets/auth/icon.svg" alt="Misskey"/></footer> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -import XForm from './form.vue'; - -export default Vue.extend({ - i18n: i18n('auth/views/index.vue'), - components: { - XForm - }, - data() { - return { - state: null, - session: null, - fetching: true - }; - }, - computed: { - token(): string { - return this.$route.params.token; - } - }, - mounted() { - if (!this.$store.getters.isSignedIn) return; - - // Fetch session - this.$root.api('auth/session/show', { - token: this.token - }).then(session => { - this.session = session; - this.fetching = false; - - // 既に連携していた場合 - if (this.session.app.isAuthorized) { - this.$root.api('auth/accept', { - token: this.session.token - }).then(() => { - this.accepted(); - }); - } else { - this.state = 'waiting'; - } - }).catch(error => { - this.state = 'fetch-session-error'; - this.fetching = false; - }); - }, - methods: { - accepted() { - this.state = 'accepted'; - if (this.session.app.callbackUrl) { - location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`; - } - } - } -}); -</script> - -<style lang="stylus" scoped> -.index - - > main - width 100% - max-width 500px - margin 0 auto - text-align center - background #fff - box-shadow 0 4px 16px rgba(#000, 0.2) - - > .fetching - margin 0 - padding 32px - color #555 - - > div:not(.form) - padding 64px - - > h1 - margin 0 0 8px 0 - padding 0 - font-size 20px - font-weight normal - - > p - margin 0 - color #555 - - &.denied > h1 - color #e65050 - - &.accepted > h1 - color #54af7c - - &.signin - padding 32px 32px 16px 32px - - > h1 - margin 0 0 22px 0 - padding 0 - font-size 20px - font-weight normal - color #555 - - @media (max-width 600px) - max-width none - box-shadow none - - @media (max-width 500px) - > div - > h1 - font-size 16px - - > footer - > img - display block - width 32px - height 32px - margin 16px auto - -</style> diff --git a/src/client/app/boot.js b/src/client/app/boot.js deleted file mode 100644 index 64d4629883..0000000000 --- a/src/client/app/boot.js +++ /dev/null @@ -1,171 +0,0 @@ -/** - * MISSKEY BOOT LOADER - * (ENTRY POINT) - */ - -'use strict'; - -(async function() { - // キャッシュ削除要求があれば従う - if (localStorage.getItem('shouldFlush') == 'true') { - refresh(); - return; - } - - const langs = LANGS; - - //#region Apply theme - const theme = localStorage.getItem('theme'); - if (theme) { - for (const [k, v] of Object.entries(JSON.parse(theme))) { - document.documentElement.style.setProperty(`--${k}`, v.toString()); - } - } - //#endregion - - //#region Load settings - let settings = null; - const vuex = localStorage.getItem('vuex'); - if (vuex) { - settings = JSON.parse(vuex); - } - //#endregion - - // Get the current url information - const url = new URL(location.href); - - //#region Detect app name - let app = null; - - if (`${url.pathname}/`.startsWith('/docs/')) app = 'docs'; - if (`${url.pathname}/`.startsWith('/dev/')) app = 'dev'; - if (`${url.pathname}/`.startsWith('/auth/')) app = 'auth'; - if (`${url.pathname}/`.startsWith('/admin/')) app = 'admin'; - //#endregion - - // Script version - const ver = localStorage.getItem('v') || VERSION; - - //#region Detect the user language - let lang = null; - - if (langs.includes(navigator.language)) { - lang = navigator.language; - } else { - lang = langs.find(x => x.split('-')[0] == navigator.language); - - if (lang == null) { - // Fallback - lang = 'en-US'; - } - } - - if (settings && settings.device.lang && - langs.includes(settings.device.lang)) - { - lang = settings.device.lang; - } - - localStorage.setItem('lang', lang); - //#endregion - - //#region Fetch locale data - const cachedLocale = localStorage.getItem('locale'); - const localeKey = localStorage.getItem('localeKey'); - let localeData = null; - - if (cachedLocale == null || localeKey != `${ver}.${lang}`) { - const locale = await fetch(`/assets/locales/${lang}.json?ver=${ver}`) - .then(response => response.json()); - localeData = locale; - - localStorage.setItem('locale', JSON.stringify(locale)); - localStorage.setItem('localeKey', `${ver}.${lang}`); - } else { - localeData = JSON.parse(cachedLocale); - } - //#endregion - - // Detect the user agent - const ua = navigator.userAgent.toLowerCase(); - let isMobile = /mobile|iphone|ipad|android/.test(ua) || window.innerWidth < 576; - if (settings && settings.device.appTypeForce) { - if (settings.device.appTypeForce === 'mobile') { - isMobile = true; - } else if (settings.device.appTypeForce === 'desktop') { - isMobile = false; - } - } - - // Get the <head> element - const head = document.getElementsByTagName('head')[0]; - - // If mobile, insert the viewport meta tag - if (isMobile) { - const viewport = document.getElementsByName("viewport").item(0); - viewport.content = `${viewport.content},minimum-scale=1,maximum-scale=1,user-scalable=no`; - head.appendChild(viewport); - } - - // Switch desktop or mobile version - if (app == null) { - app = isMobile ? 'mobile' : 'desktop'; - } - - // Load an app script - // Note: 'async' make it possible to load the script asyncly. - // 'defer' make it possible to run the script when the dom loaded. - const script = document.createElement('script'); - script.src = `/assets/${app}.${ver}.js`; - script.async = true; - script.defer = true; - head.appendChild(script); - - // 3秒経ってもスクリプトがロードされない場合はバージョンが古くて - // 404になっているせいかもしれないので、バージョンを確認して古ければ更新する - // - // 読み込まれたスクリプトからこのタイマーを解除できるように、 - // グローバルにタイマーIDを代入しておく - window.mkBootTimer = window.setTimeout(async () => { - // Fetch meta - const res = await fetch('/api/meta', { - method: 'POST', - cache: 'no-cache' - }); - - // Parse - const meta = await res.json(); - - // Compare versions - if (meta.version != ver) { - localStorage.setItem('v', meta.version); - - alert( - localeData.common._settings["update-available"] + - '\n' + - localeData.common._settings["update-available-desc"] - ); - refresh(); - } - }, 3000); - - function refresh() { - localStorage.setItem('shouldFlush', 'false'); - - localStorage.removeItem('locale'); - - // Clear cache (service worker) - try { - navigator.serviceWorker.controller.postMessage('clear'); - - navigator.serviceWorker.getRegistrations().then(registrations => { - for (const registration of registrations) registration.unregister(); - }); - } catch (e) { - console.error(e); - } - - // Force reload - location.reload(true); - } -})(); diff --git a/src/client/app/common/scripts/check-for-update.ts b/src/client/app/common/scripts/check-for-update.ts deleted file mode 100644 index d487915766..0000000000 --- a/src/client/app/common/scripts/check-for-update.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { version as current } from '../../config'; - -export default async function($root: any, force = false, silent = false) { - const meta = await $root.getMeta(force); - const newer = meta.version; - - if (newer != current) { - localStorage.setItem('should-refresh', 'true'); - localStorage.setItem('v', newer); - - // Clear cache (service worker) - try { - if (navigator.serviceWorker.controller) { - navigator.serviceWorker.controller.postMessage('clear'); - } - - const registrations = await navigator.serviceWorker.getRegistrations(); - for (const registration of registrations) { - registration.unregister(); - } - } catch (e) { - console.error(e); - } - - /*if (!silent) { - $root.dialog({ - title: $root.$t('@.update-available-title'), - text: $root.$t('@.update-available', { newer, current }) - }); - }*/ - - return newer; - } else { - return null; - } -} diff --git a/src/client/app/common/scripts/format-uptime.ts b/src/client/app/common/scripts/format-uptime.ts deleted file mode 100644 index 6550e4cc39..0000000000 --- a/src/client/app/common/scripts/format-uptime.ts +++ /dev/null @@ -1,25 +0,0 @@ - -/** - * Format like the uptime command - */ -export default function(sec) { - if (!sec) return sec; - - const day = Math.floor(sec / 86400); - const tod = sec % 86400; - - // Days part in string: 2 days, 1 day, null - const d = day >= 2 ? `${day} days` : day >= 1 ? `${day} day` : null; - - // Time part in string: 1 sec, 1 min, 1:01 - const t - = tod < 60 ? `${Math.floor(tod)} sec` - : tod < 3600 ? `${Math.floor(tod / 60)} min` - : `${Math.floor(tod / 60 / 60)}:${Math.floor((tod / 60) % 60).toString().padStart(2, '0')}`; - - let str = ''; - if (d) str += `${d}, `; - str += t; - - return str; -} diff --git a/src/client/app/common/scripts/get-face.ts b/src/client/app/common/scripts/get-face.ts deleted file mode 100644 index 19f2bdb064..0000000000 --- a/src/client/app/common/scripts/get-face.ts +++ /dev/null @@ -1,11 +0,0 @@ -const faces = [ - '(=^・・^=)', - 'v(\'ω\')v', - '🐡( \'-\' 🐡 )フグパンチ!!!!', - '✌️(´・_・`)✌️', - '(。>﹏<。)', - '(Δ・x・Δ)', - '(コ`・ヘ・´ケ)' -]; - -export default () => faces[Math.floor(Math.random() * faces.length)]; diff --git a/src/client/app/common/scripts/note-mixin.ts b/src/client/app/common/scripts/note-mixin.ts deleted file mode 100644 index 84e134cc32..0000000000 --- a/src/client/app/common/scripts/note-mixin.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { parse } from '../../../../mfm/parse'; -import { sum, unique } from '../../../../prelude/array'; -import shouldMuteNote from './should-mute-note'; -import MkNoteMenu from '../views/components/note-menu.vue'; -import MkReactionPicker from '../views/components/reaction-picker.vue'; -import pleaseLogin from './please-login'; -import i18n from '../../i18n'; - -function focus(el, fn) { - const target = fn(el); - if (target) { - if (target.hasAttribute('tabindex')) { - target.focus(); - } else { - focus(target, fn); - } - } -} - -type Opts = { - mobile?: boolean; -}; - -export default (opts: Opts = {}) => ({ - i18n: i18n(), - - data() { - return { - showContent: false, - hideThisNote: false, - openingMenu: false - }; - }, - - computed: { - keymap(): any { - return { - 'r': () => this.reply(true), - 'e|a|plus': () => this.react(true), - 'q': () => this.renote(true), - 'f|b': this.favorite, - 'delete|ctrl+d': this.del, - 'ctrl+q': this.renoteDirectly, - 'up|k|shift+tab': this.focusBefore, - 'down|j|tab': this.focusAfter, - //'esc': this.blur, - 'm|o': () => this.menu(true), - 's': this.toggleShowContent, - '1': () => this.reactDirectly('like'), - '2': () => this.reactDirectly('love'), - '3': () => this.reactDirectly('laugh'), - '4': () => this.reactDirectly('hmm'), - '5': () => this.reactDirectly('surprise'), - '6': () => this.reactDirectly('congrats'), - '7': () => this.reactDirectly('angry'), - '8': () => this.reactDirectly('confused'), - '9': () => this.reactDirectly('rip'), - '0': () => this.reactDirectly('pudding'), - }; - }, - - isRenote(): boolean { - return (this.note.renote && - this.note.text == null && - this.note.fileIds.length == 0 && - this.note.poll == null); - }, - - appearNote(): any { - return this.isRenote ? this.note.renote : this.note; - }, - - isMyNote(): boolean { - return this.$store.getters.isSignedIn && (this.$store.state.i.id === this.appearNote.userId); - }, - - reactionsCount(): number { - return this.appearNote.reactions - ? sum(Object.values(this.appearNote.reactions)) - : 0; - }, - - title(): string { - return ''; - }, - - urls(): string[] { - if (this.appearNote.text) { - const ast = parse(this.appearNote.text); - // TODO: 再帰的にURL要素がないか調べる - const urls = unique(ast - .filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent)) - .map(t => t.node.props.url)); - - // unique without hash - // [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ] - const removeHash = x => x.replace(/#[^#]*$/, ''); - - return urls.reduce((array, url) => { - const removed = removeHash(url); - if (!array.map(x => removeHash(x)).includes(removed)) array.push(url); - return array; - }, []); - } else { - return null; - } - } - }, - - created() { - this.hideThisNote = shouldMuteNote(this.$store.state.i, this.$store.state.settings, this.appearNote); - }, - - methods: { - reply(viaKeyboard = false) { - pleaseLogin(this.$root); - this.$root.$post({ - reply: this.appearNote, - animation: !viaKeyboard, - cb: () => { - this.focus(); - } - }); - }, - - renote(viaKeyboard = false) { - pleaseLogin(this.$root); - this.$root.$post({ - renote: this.appearNote, - animation: !viaKeyboard, - cb: () => { - this.focus(); - } - }); - }, - - renoteDirectly() { - (this as any).api('notes/create', { - renoteId: this.appearNote.id - }); - }, - - react(viaKeyboard = false) { - pleaseLogin(this.$root); - this.blur(); - const w = this.$root.new(MkReactionPicker, { - source: this.$refs.reactButton, - showFocus: viaKeyboard, - animation: !viaKeyboard - }); - w.$once('chosen', reaction => { - this.$root.api('notes/reactions/create', { - noteId: this.appearNote.id, - reaction: reaction - }).then(() => { - w.close(); - }); - }); - w.$once('closed', this.focus); - }, - - reactDirectly(reaction) { - this.$root.api('notes/reactions/create', { - noteId: this.appearNote.id, - reaction: reaction - }); - }, - - undoReact(note) { - const oldReaction = note.myReaction; - if (!oldReaction) return; - this.$root.api('notes/reactions/delete', { - noteId: note.id - }); - }, - - favorite() { - pleaseLogin(this.$root); - this.$root.api('notes/favorites/create', { - noteId: this.appearNote.id - }).then(() => { - this.$root.dialog({ - type: 'success', - splash: true - }); - }); - }, - - del() { - this.$root.dialog({ - type: 'warning', - text: this.$t('@.delete-confirm'), - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) return; - - this.$root.api('notes/delete', { - noteId: this.appearNote.id - }); - }); - }, - - menu(viaKeyboard = false) { - if (this.openingMenu) return; - this.openingMenu = true; - const w = this.$root.new(MkNoteMenu, { - source: this.$refs.menuButton, - note: this.appearNote, - animation: !viaKeyboard - }).$once('closed', () => { - this.openingMenu = false; - this.focus(); - }); - this.$once('hook:beforeDestroy', () => { - w.destroyDom(); - }); - }, - - toggleShowContent() { - this.showContent = !this.showContent; - }, - - focus() { - this.$el.focus(); - }, - - blur() { - this.$el.blur(); - }, - - focusBefore() { - focus(this.$el, e => e.previousElementSibling); - }, - - focusAfter() { - focus(this.$el, e => e.nextElementSibling); - } - } -}); diff --git a/src/client/app/common/scripts/note-subscriber.ts b/src/client/app/common/scripts/note-subscriber.ts deleted file mode 100644 index 5b31a9f9d0..0000000000 --- a/src/client/app/common/scripts/note-subscriber.ts +++ /dev/null @@ -1,149 +0,0 @@ -import Vue from 'vue'; - -export default prop => ({ - data() { - return { - connection: null - }; - }, - - computed: { - $_ns_note_(): any { - return this[prop]; - }, - - $_ns_isRenote(): boolean { - return (this.$_ns_note_.renote != null && - this.$_ns_note_.text == null && - this.$_ns_note_.fileIds.length == 0 && - this.$_ns_note_.poll == null); - }, - - $_ns_target(): any { - return this.$_ns_isRenote ? this.$_ns_note_.renote : this.$_ns_note_; - }, - }, - - created() { - if (this.$store.getters.isSignedIn) { - this.connection = this.$root.stream; - } - }, - - mounted() { - this.capture(true); - - if (this.$store.getters.isSignedIn) { - this.connection.on('_connected_', this.onStreamConnected); - } - }, - - beforeDestroy() { - this.decapture(true); - - if (this.$store.getters.isSignedIn) { - this.connection.off('_connected_', this.onStreamConnected); - } - }, - - methods: { - capture(withHandler = false) { - if (this.$store.getters.isSignedIn) { - const data = { - id: this.$_ns_target.id - } as any; - - if ( - (this.$_ns_target.visibleUserIds || []).includes(this.$store.state.i.id) || - (this.$_ns_target.mentions || []).includes(this.$store.state.i.id) - ) { - data.read = true; - } - - this.connection.send('sn', data); - if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated); - } - }, - - decapture(withHandler = false) { - if (this.$store.getters.isSignedIn) { - this.connection.send('un', { - id: this.$_ns_target.id - }); - if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated); - } - }, - - onStreamConnected() { - this.capture(); - }, - - onStreamNoteUpdated(data) { - const { type, id, body } = data; - - if (id !== this.$_ns_target.id) return; - - switch (type) { - case 'reacted': { - const reaction = body.reaction; - - if (this.$_ns_target.reactions == null) { - Vue.set(this.$_ns_target, 'reactions', {}); - } - - if (this.$_ns_target.reactions[reaction] == null) { - Vue.set(this.$_ns_target.reactions, reaction, 0); - } - - // Increment the count - this.$_ns_target.reactions[reaction]++; - - if (body.userId == this.$store.state.i.id) { - Vue.set(this.$_ns_target, 'myReaction', reaction); - } - break; - } - - case 'unreacted': { - const reaction = body.reaction; - - if (this.$_ns_target.reactions == null) { - return; - } - - if (this.$_ns_target.reactions[reaction] == null) { - return; - } - - // Decrement the count - if (this.$_ns_target.reactions[reaction] > 0) this.$_ns_target.reactions[reaction]--; - - if (body.userId == this.$store.state.i.id) { - Vue.set(this.$_ns_target, 'myReaction', null); - } - break; - } - - case 'pollVoted': { - const choice = body.choice; - this.$_ns_target.poll.choices[choice].votes++; - if (body.userId == this.$store.state.i.id) { - Vue.set(this.$_ns_target.poll.choices[choice], 'isVoted', true); - } - break; - } - - case 'deleted': { - Vue.set(this.$_ns_target, 'deletedAt', body.deletedAt); - Vue.set(this.$_ns_target, 'renote', null); - this.$_ns_target.text = null; - this.$_ns_target.fileIds = []; - this.$_ns_target.poll = null; - this.$_ns_target.geo = null; - this.$_ns_target.cw = null; - break; - } - } - }, - } -}); diff --git a/src/client/app/common/scripts/room/furniture.ts b/src/client/app/common/scripts/room/furniture.ts deleted file mode 100644 index 7734e32668..0000000000 --- a/src/client/app/common/scripts/room/furniture.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type RoomInfo = { - roomType: string; - carpetColor: string; - furnitures: Furniture[]; -}; - -export type Furniture = { - id: string; // 同じ家具が複数ある場合にそれぞれを識別するためのIDであり、家具IDではない - type: string; // こっちが家具ID(chairとか) - position: { - x: number; - y: number; - z: number; - }; - rotation: { - x: number; - y: number; - z: number; - }; - props?: Record<string, any>; -}; diff --git a/src/client/app/common/scripts/room/furnitures.json5 b/src/client/app/common/scripts/room/furnitures.json5 deleted file mode 100644 index 7c1a90a3f9..0000000000 --- a/src/client/app/common/scripts/room/furnitures.json5 +++ /dev/null @@ -1,397 +0,0 @@ -// 家具メタデータ - -// 家具にはユーザーが設定できるプロパティを設定可能です: -// -// props: { -// <propname>: <proptype> -// } -// -// proptype一覧: -// * image ... 画像選択ダイアログを出し、その画像のURLが格納されます -// * color ... 色選択コントロールを出し、選択された色が格納されます - -// 家具にカスタムテクスチャを適用できるようにするには、textureプロパティに以下の追加の情報を含めます: -// 便宜上そのUVのどの部分にカスタムテクスチャを貼り合わせるかのエリアをテクスチャエリアと呼びます。 -// UVは1024*1024だと仮定します。 -// -// <key>: { -// prop: <プロパティ名>, -// uv: { -// x: <テクスチャエリアX座標>, -// y: <テクスチャエリアY座標>, -// width: <テクスチャエリアの幅>, -// height: <テクスチャエリアの高さ>, -// }, -// } -// -// <key>には、カスタムテクスチャを適用したいメッシュ名を指定します -// <プロパティ名>には、カスタムテクスチャとして使用する画像を格納するプロパティ(前述)名を指定します - -// 家具にカスタムカラーを適用できるようにするには、colorプロパティに以下の追加の情報を含めます: -// -// <key>: <プロパティ名> -// -// <key>には、カスタムカラーを適用したいマテリアル名を指定します -// <プロパティ名>には、カスタムカラーとして使用する色を格納するプロパティ(前述)名を指定します - -[ - { - id: "milk", - place: "floor" - }, - { - id: "bed", - place: "floor" - }, - { - id: "low-table", - place: "floor", - props: { - color: 'color' - }, - color: { - Table: 'color' - } - }, - { - id: "desk", - place: "floor", - props: { - color: 'color' - }, - color: { - Board: 'color' - } - }, - { - id: "chair", - place: "floor", - props: { - color: 'color' - }, - color: { - Chair: 'color' - } - }, - { - id: "chair2", - place: "floor", - props: { - color1: 'color', - color2: 'color' - }, - color: { - Cushion: 'color1', - Leg: 'color2' - } - }, - { - id: "fan", - place: "wall" - }, - { - id: "pc", - place: "floor" - }, - { - id: "plant", - place: "floor" - }, - { - id: "plant2", - place: "floor" - }, - { - id: "eraser", - place: "floor" - }, - { - id: "pencil", - place: "floor" - }, - { - id: "pudding", - place: "floor" - }, - { - id: "cardboard-box", - place: "floor" - }, - { - id: "cardboard-box2", - place: "floor" - }, - { - id: "cardboard-box3", - place: "floor" - }, - { - id: "book", - place: "floor", - props: { - color: 'color' - }, - color: { - Cover: 'color' - } - }, - { - id: "book2", - place: "floor" - }, - { - id: "piano", - place: "floor" - }, - { - id: "facial-tissue", - place: "floor" - }, - { - id: "server", - place: "floor" - }, - { - id: "moon", - place: "floor" - }, - { - id: "corkboard", - place: "wall" - }, - { - id: "mousepad", - place: "floor", - props: { - color: 'color' - }, - color: { - Pad: 'color' - } - }, - { - id: "monitor", - place: "floor", - props: { - screen: 'image' - }, - texture: { - Screen: { - prop: 'screen', - uv: { - x: 0, - y: 434, - width: 1024, - height: 588, - }, - }, - }, - }, - { - id: "tv", - place: "floor", - props: { - screen: 'image' - }, - texture: { - Screen: { - prop: 'screen', - uv: { - x: 0, - y: 434, - width: 1024, - height: 588, - }, - }, - }, - }, - { - id: "keyboard", - place: "floor" - }, - { - id: "carpet-stripe", - place: "floor", - props: { - color1: 'color', - color2: 'color' - }, - color: { - CarpetAreaA: 'color1', - CarpetAreaB: 'color2' - }, - }, - { - id: "mat", - place: "floor", - props: { - color: 'color' - }, - color: { - Mat: 'color' - } - }, - { - id: "color-box", - place: "floor", - props: { - color: 'color' - }, - color: { - main: 'color' - } - }, - { - id: "wall-clock", - place: "wall" - }, - { - id: "cube", - place: "floor", - props: { - color: 'color' - }, - color: { - Cube: 'color' - } - }, - { - id: "photoframe", - place: "wall", - props: { - photo: 'image', - color: 'color' - }, - texture: { - Photo: { - prop: 'photo', - uv: { - x: 0, - y: 342, - width: 1024, - height: 683, - }, - }, - }, - color: { - Frame: 'color' - } - }, - { - id: "pinguin", - place: "floor", - props: { - body: 'color', - belly: 'color' - }, - color: { - Body: 'body', - Belly: 'belly', - } - }, - { - id: "rubik-cube", - place: "floor", - }, - { - id: "poster-h", - place: "wall", - props: { - picture: 'image' - }, - texture: { - Poster: { - prop: 'picture', - uv: { - x: 0, - y: 277, - width: 1024, - height: 745, - }, - }, - }, - }, - { - id: "poster-v", - place: "wall", - props: { - picture: 'image' - }, - texture: { - Poster: { - prop: 'picture', - uv: { - x: 0, - y: 0, - width: 745, - height: 1024, - }, - }, - }, - }, - { - id: "sofa", - place: "floor", - props: { - color: 'color' - }, - color: { - Sofa: 'color' - } - }, - { - id: "spiral", - place: "floor", - props: { - color: 'color' - }, - color: { - Step: 'color' - } - }, - { - id: "bin", - place: "floor", - props: { - color: 'color' - }, - color: { - Bin: 'color' - } - }, - { - id: "cup-noodle", - place: "floor" - }, - { - id: "holo-display", - place: "floor", - props: { - image: 'image' - }, - texture: { - Image_Front: { - prop: 'image', - uv: { - x: 0, - y: 0, - width: 1024, - height: 1024, - }, - }, - Image_Back: { - prop: 'image', - uv: { - x: 0, - y: 0, - width: 1024, - height: 1024, - }, - }, - }, - }, - { - id: 'energy-drink', - place: "floor", - } -] diff --git a/src/client/app/common/scripts/room/room.ts b/src/client/app/common/scripts/room/room.ts deleted file mode 100644 index c2a989c784..0000000000 --- a/src/client/app/common/scripts/room/room.ts +++ /dev/null @@ -1,776 +0,0 @@ -import autobind from 'autobind-decorator'; -import { v4 as uuid } from 'uuid'; -import * as THREE from 'three'; -import { GLTFLoader, GLTF } from 'three/examples/jsm/loaders/GLTFLoader'; -import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; -import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'; -import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; -import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js'; -import { BloomPass } from 'three/examples/jsm/postprocessing/BloomPass.js'; -import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader.js'; -import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'; -import { Furniture, RoomInfo } from './furniture'; -import { query as urlQuery } from '../../../../../prelude/url'; -const furnitureDefs = require('./furnitures.json5'); - -THREE.ImageUtils.crossOrigin = ''; - -type Options = { - graphicsQuality: Room['graphicsQuality']; - onChangeSelect: Room['onChangeSelect']; - useOrthographicCamera: boolean; -}; - -/** - * MisskeyRoom Core Engine - */ -export class Room { - private clock: THREE.Clock; - private scene: THREE.Scene; - private renderer: THREE.WebGLRenderer; - private camera: THREE.PerspectiveCamera | THREE.OrthographicCamera; - private controls: OrbitControls; - private composer: EffectComposer; - private mixers: THREE.AnimationMixer[] = []; - private furnitureControl: TransformControls; - private roomInfo: RoomInfo; - private graphicsQuality: 'cheep' | 'low' | 'medium' | 'high' | 'ultra'; - private roomObj: THREE.Object3D; - private objects: THREE.Object3D[] = []; - private selectedObject: THREE.Object3D = null; - private onChangeSelect: Function; - private isTransformMode = false; - private renderFrameRequestId: number; - - private get canvas(): HTMLCanvasElement { - return this.renderer.domElement; - } - - private get furnitures(): Furniture[] { - return this.roomInfo.furnitures; - } - - private set furnitures(furnitures: Furniture[]) { - this.roomInfo.furnitures = furnitures; - } - - private get enableShadow() { - return this.graphicsQuality != 'cheep'; - } - - private get usePostFXs() { - return this.graphicsQuality !== 'cheep' && this.graphicsQuality !== 'low'; - } - - private get shadowQuality() { - return ( - this.graphicsQuality === 'ultra' ? 16384 : - this.graphicsQuality === 'high' ? 8192 : - this.graphicsQuality === 'medium' ? 4096 : - this.graphicsQuality === 'low' ? 1024 : - 0); // cheep - } - - constructor(user, isMyRoom, roomInfo: RoomInfo, container, options: Options) { - this.roomInfo = roomInfo; - this.graphicsQuality = options.graphicsQuality; - this.onChangeSelect = options.onChangeSelect; - - this.clock = new THREE.Clock(true); - - //#region Init a scene - this.scene = new THREE.Scene(); - - const width = window.innerWidth; - const height = window.innerHeight; - - //#region Init a renderer - this.renderer = new THREE.WebGLRenderer({ - antialias: false, - stencil: false, - alpha: false, - powerPreference: - this.graphicsQuality === 'ultra' ? 'high-performance' : - this.graphicsQuality === 'high' ? 'high-performance' : - this.graphicsQuality === 'medium' ? 'default' : - this.graphicsQuality === 'low' ? 'low-power' : - 'low-power' // cheep - }); - - this.renderer.setPixelRatio(window.devicePixelRatio); - this.renderer.setSize(width, height); - this.renderer.autoClear = false; - this.renderer.setClearColor(new THREE.Color(0x051f2d)); - this.renderer.shadowMap.enabled = this.enableShadow; - this.renderer.shadowMap.type = - this.graphicsQuality === 'ultra' ? THREE.PCFSoftShadowMap : - this.graphicsQuality === 'high' ? THREE.PCFSoftShadowMap : - this.graphicsQuality === 'medium' ? THREE.PCFShadowMap : - this.graphicsQuality === 'low' ? THREE.BasicShadowMap : - THREE.BasicShadowMap; // cheep - - container.appendChild(this.canvas); - //#endregion - - //#region Init a camera - this.camera = options.useOrthographicCamera - ? new THREE.OrthographicCamera( - width / - 2, width / 2, height / 2, height / - 2, -10, 10) - : new THREE.PerspectiveCamera(45, width / height); - - if (options.useOrthographicCamera) { - this.camera.position.x = 2; - this.camera.position.y = 2; - this.camera.position.z = 2; - this.camera.zoom = 100; - this.camera.updateProjectionMatrix(); - } else { - this.camera.position.x = 5; - this.camera.position.y = 2; - this.camera.position.z = 5; - } - - this.scene.add(this.camera); - //#endregion - - //#region AmbientLight - const ambientLight = new THREE.AmbientLight(0xffffff, 1); - this.scene.add(ambientLight); - //#endregion - - if (this.graphicsQuality !== 'cheep') { - //#region Room light - const roomLight = new THREE.SpotLight(0xffffff, 0.1); - - roomLight.position.set(0, 8, 0); - roomLight.castShadow = this.enableShadow; - roomLight.shadow.bias = -0.0001; - roomLight.shadow.mapSize.width = this.shadowQuality; - roomLight.shadow.mapSize.height = this.shadowQuality; - roomLight.shadow.camera.near = 0.1; - roomLight.shadow.camera.far = 9; - roomLight.shadow.camera.fov = 45; - - this.scene.add(roomLight); - //#endregion - } - - //#region Out light - const outLight1 = new THREE.SpotLight(0xffffff, 0.4); - outLight1.position.set(9, 3, -2); - outLight1.castShadow = this.enableShadow; - outLight1.shadow.bias = -0.001; // アクネ、アーチファクト対策 その代わりピーターパンが発生する可能性がある - outLight1.shadow.mapSize.width = this.shadowQuality; - outLight1.shadow.mapSize.height = this.shadowQuality; - outLight1.shadow.camera.near = 6; - outLight1.shadow.camera.far = 15; - outLight1.shadow.camera.fov = 45; - this.scene.add(outLight1); - - const outLight2 = new THREE.SpotLight(0xffffff, 0.2); - outLight2.position.set(-2, 3, 9); - outLight2.castShadow = false; - outLight2.shadow.bias = -0.001; // アクネ、アーチファクト対策 その代わりピーターパンが発生する可能性がある - outLight2.shadow.camera.near = 6; - outLight2.shadow.camera.far = 15; - outLight2.shadow.camera.fov = 45; - this.scene.add(outLight2); - //#endregion - - //#region Init a controller - this.controls = new OrbitControls(this.camera, this.canvas); - - this.controls.target.set(0, 1, 0); - this.controls.enableZoom = true; - this.controls.enablePan = isMyRoom; - this.controls.minPolarAngle = 0; - this.controls.maxPolarAngle = Math.PI / 2; - this.controls.minAzimuthAngle = 0; - this.controls.maxAzimuthAngle = Math.PI / 2; - this.controls.enableDamping = true; - this.controls.dampingFactor = 0.2; - this.controls.mouseButtons.LEFT = 1; - this.controls.mouseButtons.MIDDLE = 2; - this.controls.mouseButtons.RIGHT = 0; - //#endregion - - //#region POST FXs - if (!this.usePostFXs) { - this.composer = null; - } else { - const renderTarget = new THREE.WebGLRenderTarget(width, height, { - minFilter: THREE.LinearFilter, - magFilter: THREE.LinearFilter, - format: THREE.RGBFormat, - stencilBuffer: false, - }); - - const fxaa = new ShaderPass(FXAAShader); - fxaa.uniforms['resolution'].value = new THREE.Vector2(1 / width, 1 / height); - fxaa.renderToScreen = true; - - this.composer = new EffectComposer(this.renderer, renderTarget); - this.composer.addPass(new RenderPass(this.scene, this.camera)); - if (this.graphicsQuality === 'ultra') { - this.composer.addPass(new BloomPass(0.25, 30, 128.0, 512)); - } - this.composer.addPass(fxaa); - } - //#endregion - //#endregion - - //#region Label - //#region Avatar - const avatarUrl = `/proxy/?${urlQuery({ url: user.avatarUrl })}`; - - const textureLoader = new THREE.TextureLoader(); - textureLoader.crossOrigin = 'anonymous'; - - const iconTexture = textureLoader.load(avatarUrl); - iconTexture.wrapS = THREE.RepeatWrapping; - iconTexture.wrapT = THREE.RepeatWrapping; - iconTexture.anisotropy = 16; - - const avatarMaterial = new THREE.MeshBasicMaterial({ - map: iconTexture, - side: THREE.DoubleSide, - alphaTest: 0.5 - }); - - const iconGeometry = new THREE.PlaneGeometry(1, 1); - - const avatarObject = new THREE.Mesh(iconGeometry, avatarMaterial); - avatarObject.position.set(-3, 2.5, 2); - avatarObject.rotation.y = Math.PI / 2; - avatarObject.castShadow = false; - - this.scene.add(avatarObject); - //#endregion - - //#region Username - const name = user.username; - - new THREE.FontLoader().load('/assets/fonts/helvetiker_regular.typeface.json', font => { - const nameGeometry = new THREE.TextGeometry(name, { - size: 0.5, - height: 0, - curveSegments: 8, - font: font, - bevelThickness: 0, - bevelSize: 0, - bevelEnabled: false - }); - - const nameMaterial = new THREE.MeshLambertMaterial({ - color: 0xffffff - }); - - const nameObject = new THREE.Mesh(nameGeometry, nameMaterial); - nameObject.position.set(-3, 2.25, 1.25); - nameObject.rotation.y = Math.PI / 2; - nameObject.castShadow = false; - - this.scene.add(nameObject); - }); - //#endregion - //#endregion - - //#region Interaction - if (isMyRoom) { - this.furnitureControl = new TransformControls(this.camera, this.canvas); - this.scene.add(this.furnitureControl); - - // Hover highlight - this.canvas.onmousemove = this.onmousemove; - - // Click - this.canvas.onmousedown = this.onmousedown; - } - //#endregion - - //#region Init room - this.loadRoom(); - //#endregion - - //#region Load furnitures - for (const furniture of this.furnitures) { - this.loadFurniture(furniture).then(obj => { - this.scene.add(obj.scene); - this.objects.push(obj.scene); - }); - } - //#endregion - - // Start render - if (this.usePostFXs) { - this.renderWithPostFXs(); - } else { - this.renderWithoutPostFXs(); - } - } - - @autobind - private renderWithoutPostFXs() { - this.renderFrameRequestId = - window.requestAnimationFrame(this.renderWithoutPostFXs); - - // Update animations - const clock = this.clock.getDelta(); - for (const mixer of this.mixers) { - mixer.update(clock); - } - - this.controls.update(); - this.renderer.render(this.scene, this.camera); - } - - @autobind - private renderWithPostFXs() { - this.renderFrameRequestId = - window.requestAnimationFrame(this.renderWithPostFXs); - - // Update animations - const clock = this.clock.getDelta(); - for (const mixer of this.mixers) { - mixer.update(clock); - } - - this.controls.update(); - this.renderer.clear(); - this.composer.render(); - } - - @autobind - private loadRoom() { - const type = this.roomInfo.roomType; - new GLTFLoader().load(`/assets/room/rooms/${type}/${type}.glb`, gltf => { - gltf.scene.traverse(child => { - if (!(child instanceof THREE.Mesh)) return; - - child.receiveShadow = this.enableShadow; - - child.material = new THREE.MeshLambertMaterial({ - color: (child.material as THREE.MeshStandardMaterial).color, - map: (child.material as THREE.MeshStandardMaterial).map, - name: (child.material as THREE.MeshStandardMaterial).name, - }); - - // 異方性フィルタリング - if ((child.material as THREE.MeshLambertMaterial).map && this.graphicsQuality !== 'cheep') { - (child.material as THREE.MeshLambertMaterial).map.minFilter = THREE.LinearMipMapLinearFilter; - (child.material as THREE.MeshLambertMaterial).map.magFilter = THREE.LinearMipMapLinearFilter; - (child.material as THREE.MeshLambertMaterial).map.anisotropy = 8; - } - }); - - gltf.scene.position.set(0, 0, 0); - - this.scene.add(gltf.scene); - this.roomObj = gltf.scene; - if (this.roomInfo.roomType === 'default') { - this.applyCarpetColor(); - } - }); - } - - @autobind - private loadFurniture(furniture: Furniture) { - const def = furnitureDefs.find(d => d.id === furniture.type); - return new Promise<GLTF>((res, rej) => { - const loader = new GLTFLoader(); - loader.load(`/assets/room/furnitures/${furniture.type}/${furniture.type}.glb`, gltf => { - const model = gltf.scene; - - // Load animation - if (gltf.animations.length > 0) { - const mixer = new THREE.AnimationMixer(model); - this.mixers.push(mixer); - for (const clip of gltf.animations) { - mixer.clipAction(clip).play(); - } - } - - model.name = furniture.id; - model.position.x = furniture.position.x; - model.position.y = furniture.position.y; - model.position.z = furniture.position.z; - model.rotation.x = furniture.rotation.x; - model.rotation.y = furniture.rotation.y; - model.rotation.z = furniture.rotation.z; - - model.traverse(child => { - if (!(child instanceof THREE.Mesh)) return; - child.castShadow = this.enableShadow; - child.receiveShadow = this.enableShadow; - (child.material as THREE.MeshStandardMaterial).metalness = 0; - - // 異方性フィルタリング - if ((child.material as THREE.MeshStandardMaterial).map && this.graphicsQuality !== 'cheep') { - (child.material as THREE.MeshStandardMaterial).map.minFilter = THREE.LinearMipMapLinearFilter; - (child.material as THREE.MeshStandardMaterial).map.magFilter = THREE.LinearMipMapLinearFilter; - (child.material as THREE.MeshStandardMaterial).map.anisotropy = 8; - } - }); - - if (def.color) { // カスタムカラー - this.applyCustomColor(model); - } - - if (def.texture) { // カスタムテクスチャ - this.applyCustomTexture(model); - } - - res(gltf); - }, null, rej); - }); - } - - @autobind - private applyCarpetColor() { - this.roomObj.traverse(child => { - if (!(child instanceof THREE.Mesh)) return; - if (child.material && - (child.material as THREE.MeshStandardMaterial).name && - (child.material as THREE.MeshStandardMaterial).name === 'Carpet' - ) { - const colorHex = parseInt(this.roomInfo.carpetColor.substr(1), 16); - (child.material as THREE.MeshStandardMaterial).color.setHex(colorHex); - } - }); - } - - @autobind - private applyCustomColor(model: THREE.Object3D) { - const furniture = this.furnitures.find(furniture => furniture.id === model.name); - const def = furnitureDefs.find(d => d.id === furniture.type); - if (def.color == null) return; - model.traverse(child => { - if (!(child instanceof THREE.Mesh)) return; - for (const t of Object.keys(def.color)) { - if (!child.material || - !(child.material as THREE.MeshStandardMaterial).name || - (child.material as THREE.MeshStandardMaterial).name !== t - ) continue; - - const prop = def.color[t]; - const val = furniture.props ? furniture.props[prop] : undefined; - - if (val == null) continue; - - const colorHex = parseInt(val.substr(1), 16); - (child.material as THREE.MeshStandardMaterial).color.setHex(colorHex); - } - }); - } - - @autobind - private applyCustomTexture(model: THREE.Object3D) { - const furniture = this.furnitures.find(furniture => furniture.id === model.name); - const def = furnitureDefs.find(d => d.id === furniture.type); - if (def.texture == null) return; - - model.traverse(child => { - if (!(child instanceof THREE.Mesh)) return; - for (const t of Object.keys(def.texture)) { - if (child.name !== t) continue; - - const prop = def.texture[t].prop; - const val = furniture.props ? furniture.props[prop] : undefined; - - if (val == null) continue; - - const canvas = document.createElement('canvas'); - canvas.height = 1024; - canvas.width = 1024; - - child.material = new THREE.MeshLambertMaterial({ - emissive: 0x111111, - side: THREE.DoubleSide, - alphaTest: 0.5, - }); - - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.onload = () => { - const uvInfo = def.texture[t].uv; - - const ctx = canvas.getContext('2d'); - ctx.drawImage(img, - 0, 0, img.width, img.height, - uvInfo.x, uvInfo.y, uvInfo.width, uvInfo.height); - - const texture = new THREE.Texture(canvas); - texture.wrapS = THREE.RepeatWrapping; - texture.wrapT = THREE.RepeatWrapping; - texture.anisotropy = 16; - texture.flipY = false; - - (child.material as THREE.MeshLambertMaterial).map = texture; - (child.material as THREE.MeshLambertMaterial).needsUpdate = true; - (child.material as THREE.MeshLambertMaterial).map.needsUpdate = true; - }; - img.src = val; - } - }); - } - - @autobind - private onmousemove(ev: MouseEvent) { - if (this.isTransformMode) return; - - const rect = (ev.target as HTMLElement).getBoundingClientRect(); - const x = (((ev.clientX * window.devicePixelRatio) - rect.left) / this.canvas.width) * 2 - 1; - const y = -(((ev.clientY * window.devicePixelRatio) - rect.top) / this.canvas.height) * 2 + 1; - const pos = new THREE.Vector2(x, y); - - this.camera.updateMatrixWorld(); - - const raycaster = new THREE.Raycaster(); - raycaster.setFromCamera(pos, this.camera); - - const intersects = raycaster.intersectObjects(this.objects, true); - - for (const object of this.objects) { - if (this.isSelectedObject(object)) continue; - object.traverse(child => { - if (child instanceof THREE.Mesh) { - (child.material as THREE.MeshStandardMaterial).emissive.setHex(0x000000); - } - }); - } - - if (intersects.length > 0) { - const intersected = this.getRoot(intersects[0].object); - if (this.isSelectedObject(intersected)) return; - intersected.traverse(child => { - if (child instanceof THREE.Mesh) { - (child.material as THREE.MeshStandardMaterial).emissive.setHex(0x191919); - } - }); - } - } - - @autobind - private onmousedown(ev: MouseEvent) { - if (this.isTransformMode) return; - if (ev.target !== this.canvas || ev.button !== 0) return; - - const rect = (ev.target as HTMLElement).getBoundingClientRect(); - const x = (((ev.clientX * window.devicePixelRatio) - rect.left) / this.canvas.width) * 2 - 1; - const y = -(((ev.clientY * window.devicePixelRatio) - rect.top) / this.canvas.height) * 2 + 1; - const pos = new THREE.Vector2(x, y); - - this.camera.updateMatrixWorld(); - - const raycaster = new THREE.Raycaster(); - raycaster.setFromCamera(pos, this.camera); - - const intersects = raycaster.intersectObjects(this.objects, true); - - for (const object of this.objects) { - object.traverse(child => { - if (child instanceof THREE.Mesh) { - (child.material as THREE.MeshStandardMaterial).emissive.setHex(0x000000); - } - }); - } - - if (intersects.length > 0) { - const selectedObj = this.getRoot(intersects[0].object); - this.selectFurniture(selectedObj); - } else { - this.selectedObject = null; - this.onChangeSelect(null); - } - } - - @autobind - private getRoot(obj: THREE.Object3D): THREE.Object3D { - let found = false; - let x = obj.parent; - while (!found) { - if (x.parent.parent == null) { - found = true; - } else { - x = x.parent; - } - } - return x; - } - - @autobind - private isSelectedObject(obj: THREE.Object3D): boolean { - if (this.selectedObject == null) { - return false; - } else { - return obj.name === this.selectedObject.name; - } - } - - @autobind - private selectFurniture(obj: THREE.Object3D) { - this.selectedObject = obj; - this.onChangeSelect(obj); - obj.traverse(child => { - if (child instanceof THREE.Mesh) { - (child.material as THREE.MeshStandardMaterial).emissive.setHex(0xff0000); - } - }); - } - - /** - * 家具の移動/回転モードにします - * @param type 移動か回転か - */ - @autobind - public enterTransformMode(type: 'translate' | 'rotate') { - this.isTransformMode = true; - this.furnitureControl.setMode(type); - this.furnitureControl.attach(this.selectedObject); - } - - /** - * 家具の移動/回転モードを終了します - */ - @autobind - public exitTransformMode() { - this.isTransformMode = false; - this.furnitureControl.detach(); - } - - /** - * 家具プロパティを更新します - * @param key プロパティ名 - * @param value 値 - */ - @autobind - public updateProp(key: string, value: any) { - const furniture = this.furnitures.find(furniture => furniture.id === this.selectedObject.name); - if (furniture.props == null) furniture.props = {}; - furniture.props[key] = value; - this.applyCustomColor(this.selectedObject); - this.applyCustomTexture(this.selectedObject); - } - - /** - * 部屋に家具を追加します - * @param type 家具の種類 - */ - @autobind - public addFurniture(type: string) { - const furniture = { - id: uuid(), - type: type, - position: { - x: 0, - y: 0, - z: 0, - }, - rotation: { - x: 0, - y: 0, - z: 0, - }, - }; - - this.furnitures.push(furniture); - - this.loadFurniture(furniture).then(obj => { - this.scene.add(obj.scene); - this.objects.push(obj.scene); - }); - } - - /** - * 現在選択されている家具を部屋から削除します - */ - @autobind - public removeFurniture() { - this.exitTransformMode(); - const obj = this.selectedObject; - this.scene.remove(obj); - this.objects = this.objects.filter(object => object.name !== obj.name); - this.furnitures = this.furnitures.filter(furniture => furniture.id !== obj.name); - this.selectedObject = null; - this.onChangeSelect(null); - } - - /** - * 全ての家具を部屋から削除します - */ - @autobind - public removeAllFurnitures() { - this.exitTransformMode(); - for (const obj of this.objects) { - this.scene.remove(obj); - } - this.objects = []; - this.furnitures = []; - this.selectedObject = null; - this.onChangeSelect(null); - } - - /** - * 部屋の床の色を変更します - * @param color 色 - */ - @autobind - public updateCarpetColor(color: string) { - this.roomInfo.carpetColor = color; - this.applyCarpetColor(); - } - - /** - * 部屋の種類を変更します - * @param type 種類 - */ - @autobind - public changeRoomType(type: string) { - this.roomInfo.roomType = type; - this.scene.remove(this.roomObj); - this.loadRoom(); - } - - /** - * 部屋データを取得します - */ - @autobind - public getRoomInfo() { - for (const obj of this.objects) { - const furniture = this.furnitures.find(f => f.id === obj.name); - furniture.position.x = obj.position.x; - furniture.position.y = obj.position.y; - furniture.position.z = obj.position.z; - furniture.rotation.x = obj.rotation.x; - furniture.rotation.y = obj.rotation.y; - furniture.rotation.z = obj.rotation.z; - } - - return this.roomInfo; - } - - /** - * 選択されている家具を取得します - */ - @autobind - public getSelectedObject() { - return this.selectedObject; - } - - @autobind - public findFurnitureById(id: string) { - return this.furnitures.find(furniture => furniture.id === id); - } - - /** - * レンダリングを終了します - */ - @autobind - public destroy() { - // Stop render loop - window.cancelAnimationFrame(this.renderFrameRequestId); - - this.controls.dispose(); - this.scene.dispose(); - } -} diff --git a/src/client/app/common/scripts/should-mute-note.ts b/src/client/app/common/scripts/should-mute-note.ts deleted file mode 100644 index 8fd7888628..0000000000 --- a/src/client/app/common/scripts/should-mute-note.ts +++ /dev/null @@ -1,19 +0,0 @@ -export default function(me, settings, note) { - const isMyNote = me && (note.userId == me.id); - const isPureRenote = note.renoteId != null && note.text == null && note.fileIds.length == 0 && note.poll == null; - - const includesMutedWords = (text: string) => - text - ? settings.mutedWords.some(q => q.length > 0 && !q.some(word => - word.startsWith('/') && word.endsWith('/') ? !(new RegExp(word.substr(1, word.length - 2)).test(text)) : !text.includes(word))) - : false; - - return ( - (!isMyNote && note.reply && includesMutedWords(note.reply.text)) || - (!isMyNote && note.renote && includesMutedWords(note.renote.text)) || - (!settings.showMyRenotes && isMyNote && isPureRenote) || - (!settings.showRenotedMyNotes && isPureRenote && note.renote.userId == me.id) || - (!settings.showLocalRenotes && isPureRenote && note.renote.user.host == null) || - (!isMyNote && includesMutedWords(note.text)) - ); -} diff --git a/src/client/app/common/size.ts b/src/client/app/common/size.ts deleted file mode 100644 index 6abb305747..0000000000 --- a/src/client/app/common/size.ts +++ /dev/null @@ -1,18 +0,0 @@ -export default { - install(Vue) { - Vue.directive('size', { - inserted(el, binding) { - const query = binding.value; - const width = el.clientWidth; - for (const q of query) { - if (q.lt && (width <= q.lt)) { - el.classList.add(q.class); - } - if (q.gt && (width >= q.gt)) { - el.classList.add(q.class); - } - } - } - }); - } -}; diff --git a/src/client/app/common/views/components/acct.vue b/src/client/app/common/views/components/acct.vue deleted file mode 100644 index e802000833..0000000000 --- a/src/client/app/common/views/components/acct.vue +++ /dev/null @@ -1,31 +0,0 @@ -<template> -<span class="mk-acct" v-once> - <span class="name">@{{ user.username }}</span> - <span class="host" :class="{ fade: $store.state.settings.contrastedAcct }" v-if="user.host || detail || $store.state.settings.showFullAcct">@{{ user.host || host }}</span> - <fa v-if="user.isLocked == true" class="locked" icon="lock" fixed-width/> -</span> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { host } from '../../../config'; -import { toUnicode } from 'punycode'; -export default Vue.extend({ - props: ['user', 'detail'], - data() { - return { - host: toUnicode(host) - }; - } -}); -</script> - -<style lang="stylus" scoped> -.mk-acct - > .host.fade - opacity 0.5 - - > .locked - opacity 0.8 - margin-left 0.5em -</style> diff --git a/src/client/app/common/views/components/analog-clock.vue b/src/client/app/common/views/components/analog-clock.vue deleted file mode 100644 index 5eb7ffd153..0000000000 --- a/src/client/app/common/views/components/analog-clock.vue +++ /dev/null @@ -1,140 +0,0 @@ -<template> -<svg class="mk-analog-clock" viewBox="0 0 10 10" preserveAspectRatio="none"> - <circle v-for="angle, i in graduations" - :cx="5 + (Math.sin(angle) * (5 - graduationsPadding))" - :cy="5 - (Math.cos(angle) * (5 - graduationsPadding))" - :r="i % 5 == 0 ? 0.125 : 0.05" - :fill="i % 5 == 0 ? majorGraduationColor : minorGraduationColor"/> - - <line - :x1="5 - (Math.sin(sAngle) * (sHandLengthRatio * handsTailLength))" - :y1="5 + (Math.cos(sAngle) * (sHandLengthRatio * handsTailLength))" - :x2="5 + (Math.sin(sAngle) * ((sHandLengthRatio * 5) - handsPadding))" - :y2="5 - (Math.cos(sAngle) * ((sHandLengthRatio * 5) - handsPadding))" - :stroke="sHandColor" - stroke-width="0.05"/> - <line - :x1="5 - (Math.sin(mAngle) * (mHandLengthRatio * handsTailLength))" - :y1="5 + (Math.cos(mAngle) * (mHandLengthRatio * handsTailLength))" - :x2="5 + (Math.sin(mAngle) * ((mHandLengthRatio * 5) - handsPadding))" - :y2="5 - (Math.cos(mAngle) * ((mHandLengthRatio * 5) - handsPadding))" - :stroke="mHandColor" - stroke-width="0.1"/> - <line - :x1="5 - (Math.sin(hAngle) * (hHandLengthRatio * handsTailLength))" - :y1="5 + (Math.cos(hAngle) * (hHandLengthRatio * handsTailLength))" - :x2="5 + (Math.sin(hAngle) * ((hHandLengthRatio * 5) - handsPadding))" - :y2="5 - (Math.cos(hAngle) * ((hHandLengthRatio * 5) - handsPadding))" - :stroke="hHandColor" - stroke-width="0.1"/> -</svg> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import * as tinycolor from 'tinycolor2'; - -export default Vue.extend({ - props: { - dark: { - type: Boolean, - default: false - }, - smooth: { - type: Boolean, - default: false - } - }, - - data() { - return { - now: new Date(), - enabled: true, - - graduationsPadding: 0.5, - handsPadding: 1, - handsTailLength: 0.7, - hHandLengthRatio: 0.75, - mHandLengthRatio: 1, - sHandLengthRatio: 1 - }; - }, - - computed: { - majorGraduationColor(): string { - return this.dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'; - }, - minorGraduationColor(): string { - return this.dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; - }, - - sHandColor(): string { - return this.dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)'; - }, - mHandColor(): string { - return this.dark ? '#fff' : '#777'; - }, - hHandColor(): string { - return tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--primary')).toHexString(); - }, - - ms(): number { - return this.now.getMilliseconds() * this.smooth; - }, - s(): number { - return this.now.getSeconds(); - }, - m(): number { - return this.now.getMinutes(); - }, - h(): number { - return this.now.getHours(); - }, - - hAngle(): number { - return Math.PI * (this.h % 12 + (this.m + (this.s + this.ms / 1000) / 60) / 60) / 6; - }, - mAngle(): number { - return Math.PI * (this.m + (this.s + this.ms / 1000) / 60) / 30; - }, - sAngle(): number { - return Math.PI * (this.s + this.ms / 1000) / 30; - }, - - graduations(): any { - const angles = []; - for (let i = 0; i < 60; i++) { - const angle = Math.PI * i / 30; - angles.push(angle); - } - - return angles; - } - }, - - mounted() { - const update = () => { - if (this.enabled) { - this.tick(); - requestAnimationFrame(update); - } - }; - update(); - }, - - beforeDestroy() { - this.enabled = false; - }, - - methods: { - tick() { - this.now = new Date(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-analog-clock - display block -</style> diff --git a/src/client/app/common/views/components/avatar.vue b/src/client/app/common/views/components/avatar.vue deleted file mode 100644 index cd02c6957d..0000000000 --- a/src/client/app/common/views/components/avatar.vue +++ /dev/null @@ -1,116 +0,0 @@ -<template> - <span class="mk-avatar" :style="style" :class="{ cat }" :title="user | acct" v-if="disableLink && !disablePreview" v-user-preview="user.id" @click="onClick" v-once> - <span class="inner" :style="icon"></span> - </span> - <span class="mk-avatar" :style="style" :class="{ cat }" :title="user | acct" v-else-if="disableLink && disablePreview" @click="onClick" v-once> - <span class="inner" :style="icon"></span> - </span> - <router-link class="mk-avatar" :style="style" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && !disablePreview" v-user-preview="user.id" v-once> - <span class="inner" :style="icon"></span> - </router-link> - <router-link class="mk-avatar" :style="style" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && disablePreview" v-once> - <span class="inner" :style="icon"></span> - </router-link> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { getStaticImageUrl } from '../../../common/scripts/get-static-image-url'; - -export default Vue.extend({ - props: { - user: { - type: Object, - required: true - }, - target: { - required: false, - default: null - }, - disableLink: { - required: false, - default: false - }, - disablePreview: { - required: false, - default: false - } - }, - computed: { - lightmode(): boolean { - return this.$store.state.device.lightmode; - }, - cat(): boolean { - return this.user.isCat && this.$store.state.settings.circleIcons; - }, - style(): any { - return { - borderRadius: this.$store.state.settings.circleIcons ? '100%' : null - }; - }, - url(): string { - return this.$store.state.device.disableShowingAnimatedImages - ? getStaticImageUrl(this.user.avatarUrl) - : this.user.avatarUrl; - }, - icon(): any { - return { - backgroundColor: this.user.avatarColor, - backgroundImage: this.lightmode ? null : `url(${this.url})`, - borderRadius: this.$store.state.settings.circleIcons ? '100%' : null - }; - } - }, - mounted() { - if (this.user.avatarColor) { - this.$el.style.color = this.user.avatarColor; - } - }, - methods: { - onClick(e) { - this.$emit('click', e); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-avatar - display inline-block - vertical-align bottom - flex-shrink 0 - - &:not(.cat) - overflow hidden - border-radius 8px - - &.cat::before, - &.cat::after - background #df548f - border solid 4px currentColor - box-sizing border-box - content '' - display inline-block - height 50% - width 50% - - &.cat::before - border-radius 0 75% 75% - transform rotate(37.5deg) skew(30deg) - - &.cat::after - border-radius 75% 0 75% 75% - transform rotate(-37.5deg) skew(-30deg) - - .inner - background-position center center - background-size cover - bottom 0 - left 0 - position absolute - right 0 - top 0 - transition border-radius 1s ease - z-index 1 - -</style> diff --git a/src/client/app/common/views/components/connect-failed.troubleshooter.vue b/src/client/app/common/views/components/connect-failed.troubleshooter.vue deleted file mode 100644 index 19b8c3e974..0000000000 --- a/src/client/app/common/views/components/connect-failed.troubleshooter.vue +++ /dev/null @@ -1,148 +0,0 @@ -<template> -<div class="troubleshooter"> - <div class="body"> - <h1><fa icon="wrench"/>{{ $t('title') }}</h1> - <div> - <p :data-wip="network == null"> - <template v-if="network != null"> - <template v-if="network"><fa icon="check"/></template> - <template v-if="!network"><fa icon="times"/></template> - </template> - {{ network == null ? this.$t('checking-network') : this.$t('network') }}<mk-ellipsis v-if="network == null"/> - </p> - <p v-if="network == true" :data-wip="internet == null"> - <template v-if="internet != null"> - <template v-if="internet"><fa icon="check"/></template> - <template v-if="!internet"><fa icon="times"/></template> - </template> - {{ internet == null ? this.$t('checking-internet') : this.$t('internet') }}<mk-ellipsis v-if="internet == null"/> - </p> - <p v-if="internet == true" :data-wip="server == null"> - <template v-if="server != null"> - <template v-if="server"><fa icon="check"/></template> - <template v-if="!server"><fa icon="times"/></template> - </template> - {{ server == null ? this.$t('checking-server') : this.$t('server') }}<mk-ellipsis v-if="server == null"/> - </p> - </div> - <p v-if="!end">{{ $t('finding') }}<mk-ellipsis/></p> - <p v-if="network === false"><b><fa icon="exclamation-triangle"/>{{ $t('no-network') }}</b><br>{{ $t('no-network-desc') }}</p> - <p v-if="internet === false"><b><fa icon="exclamation-triangle"/>{{ $t('no-internet') }}</b><br>{{ $t('no-internet-desc') }}</p> - <p v-if="server === false"><b><fa icon="exclamation-triangle"/>{{ $t('no-server') }}</b><br>{{ $t('no-server-desc') }}</p> - <p v-if="server === true" class="success"><b><fa icon="info-circle"/>{{ $t('success') }}</b><br>{{ $t('success-desc') }}</p> - </div> - <footer> - <a href="/assets/flush.html">{{ $t('flush') }}</a> | <a href="/assets/version.html">{{ $t('set-version') }}</a> - </footer> -</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/connect-failed.troubleshooter.vue'), - data() { - return { - network: navigator.onLine, - end: false, - internet: null, - server: null - }; - }, - mounted() { - if (!this.network) { - this.end = true; - return; - } - - // Check internet connection - fetch(`https://google.com?rand=${Math.random()}`, { - mode: 'no-cors' - }).then(() => { - this.internet = true; - - // Check misskey server is available - fetch(`${apiUrl}/meta`).then(() => { - this.end = true; - this.server = true; - }) - .catch(() => { - this.end = true; - this.server = false; - }); - }) - .catch(() => { - this.end = true; - this.internet = false; - }); - } -}); -</script> - -<style lang="stylus" scoped> -.troubleshooter - margin-top 1em - - > .body - width 100% - max-width 500px - margin 0 auto - text-align left - background #fff - border-radius 8px - border solid 1px #ddd - - > h1 - margin 0 - padding 0.6em 1.2em - font-size 1em - color #444 - border-bottom solid 1px #eee - - > [data-icon] - margin-right 0.25em - - > div - overflow hidden - padding 0.6em 1.2em - - > p - margin 0.5em 0 - font-size 0.9em - color #444 - - &[data-wip] - color #888 - - > [data-icon] - margin-right 0.25em - - &.times - color #e03524 - - &.check - color #84c32f - - > p - margin 0 - padding 0.7em 1.2em - font-size 1em - color #444 - border-top solid 1px #eee - - > b - > [data-icon] - margin-right 0.25em - - &.success - > b - color #39adad - - &:not(.success) - > b - color #ad4339 - -</style> diff --git a/src/client/app/common/views/components/connect-failed.vue b/src/client/app/common/views/components/connect-failed.vue deleted file mode 100644 index a364304a63..0000000000 --- a/src/client/app/common/views/components/connect-failed.vue +++ /dev/null @@ -1,105 +0,0 @@ -<template> -<div class="mk-connect-failed"> - <img src="/assets/error.jpg" onerror="this.src='https://raw.githubusercontent.com/syuilo/misskey/develop/src/client/assets/error.jpg';" alt=""/> - <h1>{{ $t('title') }}</h1> - <p class="text"> - <span>{{ this.$t('description').substr(0, this.$t('description').indexOf('{')) }}</span> - <a @click="reload">{{ this.$t('description').match(/\{(.+?)\}/)[1] }}</a> - <span>{{ this.$t('description').substr(this.$t('description').indexOf('}') + 1) }}</span> - </p> - <button v-if="!troubleshooting" @click="troubleshooting = true">{{ $t('troubleshoot') }}</button> - <x-troubleshooter v-if="troubleshooting"/> - <p class="thanks">{{ $t('thanks') }}</p> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XTroubleshooter from './connect-failed.troubleshooter.vue'; - -export default Vue.extend({ - i18n: i18n('common/views/components/connect-failed.vue'), - components: { - XTroubleshooter - }, - data() { - return { - troubleshooting: false - }; - }, - mounted() { - document.title = 'Oops!'; - document.documentElement.style.setProperty('background', '#f8f8f8', 'important'); - }, - methods: { - reload() { - location.reload(true); - } - } -}); -</script> - -<style lang="stylus" scoped> - - -.mk-connect-failed - width 100% - padding 32px 18px - text-align center - - > img - display block - height 200px - margin 0 auto - pointer-events none - user-select none - - > h1 - display block - margin 1.25em auto 0.65em auto - font-size 1.5em - color #555 - - > .text - display block - margin 0 auto - max-width 600px - font-size 1em - color #666 - - > button - display block - margin 1em auto 0 auto - padding 8px 10px - color var(--primaryForeground) - background var(--primary) - - &:focus - outline solid 3px var(--primaryAlpha03) - - &:hover - background var(--primaryLighten10) - - &:active - background var(--primaryDarken10) - - > .thanks - display block - margin 2em auto 0 auto - padding 2em 0 0 0 - max-width 600px - font-size 0.9em - font-style oblique - color #aaa - border-top solid 1px #eee - - @media (max-width 500px) - padding 24px 18px - font-size 80% - - > img - height 150px - -</style> - diff --git a/src/client/app/common/views/components/cw-button.vue b/src/client/app/common/views/components/cw-button.vue deleted file mode 100644 index 098aa021d1..0000000000 --- a/src/client/app/common/views/components/cw-button.vue +++ /dev/null @@ -1,70 +0,0 @@ -<template> -<button class="nrvgflfuaxwgkxoynpnumyookecqrrvh" @click="toggle"> - <b>{{ value ? this.$t('hide') : this.$t('show') }}</b> - <span v-if="!value">{{ this.label }}</span> -</button> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { length } from 'stringz'; -import { concat } from '../../../../../prelude/array'; - -export default Vue.extend({ - i18n: i18n('common/views/components/cw-button.vue'), - - props: { - value: { - type: Boolean, - required: true - }, - note: { - type: Object, - required: true - } - }, - - computed: { - label(): string { - return concat([ - this.note.text ? [this.$t('chars', { count: length(this.note.text) })] : [], - this.note.files && this.note.files.length !== 0 ? [this.$t('files', { count: this.note.files.length }) ] : [], - this.note.poll != null ? [this.$t('poll')] : [] - ] as string[][]).join(' / '); - } - }, - - methods: { - length, - - toggle() { - this.$emit('input', !this.value); - } - } -}); -</script> - -<style lang="stylus" scoped> -.nrvgflfuaxwgkxoynpnumyookecqrrvh - display inline-block - padding 4px 8px - font-size 0.7em - color var(--cwButtonFg) - background var(--cwButtonBg) - border-radius 2px - cursor pointer - user-select none - - &:hover - background var(--cwButtonHoverBg) - - > span - margin-left 4px - - &:before - content '(' - &:after - content ')' - -</style> diff --git a/src/client/app/common/views/components/dialog.vue b/src/client/app/common/views/components/dialog.vue deleted file mode 100644 index 2744903007..0000000000 --- a/src/client/app/common/views/components/dialog.vue +++ /dev/null @@ -1,263 +0,0 @@ -<template> -<ui-modal - ref="modal" - class="modal" - :class="{ splash }" - :close-anime-duration="300" - :close-on-bg-click="false" - @bg-click="onBgClick" - @before-close="onBeforeClose"> - <div class="main" ref="main" :class="{ round: $store.state.device.roundedCorners }"> - <template v-if="type == 'signin'"> - <mk-signin/> - </template> - <template v-else> - <div class="icon" v-if="icon"> - <fa :icon="icon"/> - </div> - <div class="icon" v-else-if="!input && !select && !user" :class="type"> - <fa icon="check" v-if="type === 'success'"/> - <fa :icon="faTimesCircle" v-if="type === 'error'"/> - <fa icon="exclamation-triangle" v-if="type === 'warning'"/> - <fa icon="info-circle" v-if="type === 'info'"/> - <fa :icon="faQuestionCircle" v-if="type === 'question'"/> - <fa icon="spinner" pulse v-if="type === 'waiting'"/> - </div> - <header v-if="title" v-html="title"></header> - <header v-if="title == null && user">{{ $t('@.enter-username') }}</header> - <div class="body" v-if="text" v-html="text"></div> - <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"><template #prefix>@</template></ui-input> - <ui-select v-if="select" v-model="selectedValue" autofocus> - <template v-if="select.items"> - <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> - </template> - <template v-else> - <optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label"> - <option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option> - </optgroup> - </template> - </ui-select> - <ui-horizon-group no-grow class="buttons fit-bottom" v-if="!splash && (showOkButton || showCancelButton)"> - <ui-button @click="ok" v-if="showOkButton" primary :autofocus="!input && !select && !user" :disabled="!canOk">{{ (showCancelButton || input || select || user) ? $t('@.ok') : $t('@.got-it') }}</ui-button> - <ui-button @click="cancel" v-if="showCancelButton || input || select || user">{{ $t('@.cancel') }}</ui-button> - </ui-horizon-group> - </template> - </div> -</ui-modal> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import anime from 'animejs'; -import { faTimesCircle, faQuestionCircle } from '@fortawesome/free-regular-svg-icons'; -import parseAcct from "../../../../../misc/acct/parse"; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n(), - props: { - type: { - type: String, - required: false, - default: 'info' - }, - title: { - type: String, - required: false - }, - text: { - type: String, - required: false - }, - input: { - required: false - }, - select: { - required: false - }, - user: { - required: false - }, - icon: { - required: false - }, - showOkButton: { - type: Boolean, - default: true - }, - showCancelButton: { - type: Boolean, - default: false - }, - cancelableByBgClick: { - type: Boolean, - default: true - }, - splash: { - type: Boolean, - default: false - } - }, - - data() { - return { - inputValue: this.input && this.input.default ? this.input.default : null, - userInputValue: null, - selectedValue: this.select ? this.select.default ? this.select.default : this.select.items ? this.select.items[0].value : this.select.groupedItems[0].items[0].value : null, - canOk: true, - faTimesCircle, faQuestionCircle - }; - }, - - watch: { - userInputValue() { - if (this.user) { - this.$root.api('users/show', parseAcct(this.userInputValue)).then(u => { - this.canOk = u != null; - }).catch(() => { - this.canOk = false; - }); - } - } - }, - - mounted() { - if (this.user) this.canOk = false; - - this.$nextTick(() => { - anime({ - targets: this.$refs.main, - opacity: 1, - scale: [1.2, 1], - duration: 300, - easing: 'cubicBezier(0, 0.5, 0.5, 1)' - }); - - if (this.splash) { - setTimeout(() => { - this.close(); - }, 1000); - } - }); - }, - - methods: { - async ok() { - if (!this.canOk) return; - if (!this.showOkButton) return; - - 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() { - this.$emit('cancel'); - this.close(); - }, - - onBgClick() { - if (this.cancelableByBgClick) this.cancel(); - } - - close() { - this.$refs.modal.close(); - }, - - onBeforeClose() { - this.$el.style.pointerEvents = 'none'; - (this.$refs.main as any).style.pointerEvents = 'none'; - - anime({ - targets: this.$refs.main, - opacity: 0, - scale: 0.8, - duration: 300, - easing: 'cubicBezier(0, 0.5, 0.5, 1)', - }); - }, - - onInputKeydown(e) { - if (e.which == 13) { // Enter - e.preventDefault(); - e.stopPropagation(); - this.ok(); - } - } - } -}); -</script> - -<style lang="stylus" scoped> -.modal - display flex - align-items center - justify-content center - - &.splash - > .main - min-width 0 - width initial - -.main - display block - position fixed - margin auto - padding 32px - min-width 320px - max-width 480px - width calc(100% - 32px) - text-align center - background var(--face) - color var(--faceText) - opacity 0 - - &.round - border-radius 8px - - > .icon - font-size 32px - - &.success - color #85da5a - - &.error - color #ec4137 - - &.warning - color #ecb637 - - > * - display block - margin 0 auto - - & + header - margin-top 16px - - > header - margin 0 0 8px 0 - font-weight bold - font-size 20px - - & + .body - margin-top 8px - - > .body - margin 16px 0 0 0 - - > .buttons - margin-top 16px - -</style> diff --git a/src/client/app/common/views/components/dummy.vue b/src/client/app/common/views/components/dummy.vue deleted file mode 100644 index 5634efc509..0000000000 --- a/src/client/app/common/views/components/dummy.vue +++ /dev/null @@ -1,11 +0,0 @@ -<template> -<div> - <slot></slot> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ -}); -</script> diff --git a/src/client/app/common/views/components/ellipsis.vue b/src/client/app/common/views/components/ellipsis.vue deleted file mode 100644 index 07349902de..0000000000 --- a/src/client/app/common/views/components/ellipsis.vue +++ /dev/null @@ -1,26 +0,0 @@ -<template> - <span class="mk-ellipsis"> - <span>.</span><span>.</span><span>.</span> - </span> -</template> - -<style lang="stylus" scoped> -.mk-ellipsis - > span - animation ellipsis 1.4s infinite ease-in-out both - - &:nth-child(1) - animation-delay 0s - - &:nth-child(2) - animation-delay 0.16s - - &:nth-child(3) - animation-delay 0.32s - - @keyframes ellipsis - 0%, 80%, 100% - opacity 1 - 40% - opacity 0 -</style> diff --git a/src/client/app/common/views/components/emoji-picker.vue b/src/client/app/common/views/components/emoji-picker.vue deleted file mode 100644 index abae69e28a..0000000000 --- a/src/client/app/common/views/components/emoji-picker.vue +++ /dev/null @@ -1,243 +0,0 @@ -<template> -<div class="prlncendiewqqkrevzeruhndoakghvtx"> - <header> - <button v-for="category in categories" - :title="category.text" - @click="go(category)" - :class="{ active: category.isActive }" - :key="category.text" - > - <fa :icon="category.icon" fixed-width/> - </button> - </header> - <div class="emojis"> - <template v-if="categories[0].isActive"> - <header class="category"><fa :icon="faHistory" fixed-width/> {{ $t('recent-emoji') }}</header> - <div class="list"> - <button v-for="(emoji, i) in ($store.state.device.recentEmojis || [])" - :title="emoji.name" - @click="chosen(emoji)" - :key="i" - > - <mk-emoji v-if="emoji.char != null" :emoji="emoji.char"/> - <img v-else :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> - </button> - </div> - </template> - - <header class="category"><fa :icon="categories.find(x => x.isActive).icon" fixed-width/> {{ categories.find(x => x.isActive).text }}</header> - <template v-if="categories.find(x => x.isActive).name"> - <div class="list"> - <button v-for="emoji in emojilist.filter(e => e.category === categories.find(x => x.isActive).name)" - :title="emoji.name" - @click="chosen(emoji)" - :key="emoji.name" - > - <mk-emoji :emoji="emoji.char"/> - </button> - </div> - </template> - <template v-else> - <div v-for="(key, i) in Object.keys(customEmojis)" :key="i"> - <header class="sub">{{ key || $t('no-category') }}</header> - <div class="list"> - <button v-for="emoji in customEmojis[key]" - :title="emoji.name" - @click="chosen(emoji)" - :key="emoji.name" - > - <img :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> - </button> - </div> - </div> - </template> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { emojilist } from '../../../../../misc/emojilist'; -import { getStaticImageUrl } from '../../../common/scripts/get-static-image-url'; -import { faAsterisk, faLeaf, faUtensils, faFutbol, faCity, faDice, faGlobe, faHistory } from '@fortawesome/free-solid-svg-icons'; -import { faHeart, faFlag } from '@fortawesome/free-regular-svg-icons'; -import { groupByX } from '../../../../../prelude/array'; - -export default Vue.extend({ - i18n: i18n('common/views/components/emoji-picker.vue'), - - data() { - return { - emojilist, - getStaticImageUrl, - customEmojis: {}, - faGlobe, faHistory, - categories: [{ - text: this.$t('custom-emoji'), - icon: faAsterisk, - isActive: true - }, { - name: 'people', - text: this.$t('people'), - icon: ['far', 'laugh'], - isActive: false - }, { - name: 'animals_and_nature', - text: this.$t('animals-and-nature'), - icon: faLeaf, - isActive: false - }, { - name: 'food_and_drink', - text: this.$t('food-and-drink'), - icon: faUtensils, - isActive: false - }, { - name: 'activity', - text: this.$t('activity'), - icon: faFutbol, - isActive: false - }, { - name: 'travel_and_places', - text: this.$t('travel-and-places'), - icon: faCity, - isActive: false - }, { - name: 'objects', - text: this.$t('objects'), - icon: faDice, - isActive: false - }, { - name: 'symbols', - text: this.$t('symbols'), - icon: faHeart, - isActive: false - }, { - name: 'flags', - text: this.$t('flags'), - icon: faFlag, - isActive: false - }] - } - }, - - created() { - let local = (this.$root.getMetaSync() || { emojis: [] }).emojis || []; - local = groupByX(local, (x: any) => x.category || ''); - this.customEmojis = local; - - if (this.$store.state.device.activeEmojiCategoryName) { - this.goCategory(this.$store.state.device.activeEmojiCategoryName); - } - }, - - methods: { - go(category: any) { - this.goCategory(category.name); - }, - - goCategory(name: string) { - let matched = false; - for (const c of this.categories) { - c.isActive = c.name === name; - if (c.isActive) { - matched = true; - this.$store.commit('device/set', { key: 'activeEmojiCategoryName', value: c.name }); - } - } - if (!matched) { - this.categories[0].isActive = true; - } - }, - - chosen(emoji: any) { - const getKey = (emoji: any) => emoji.char || `:${emoji.name}:`; - - let recents = this.$store.state.device.recentEmojis || []; - recents = recents.filter((e: any) => getKey(e) !== getKey(emoji)); - recents.unshift(emoji) - this.$store.commit('device/set', { key: 'recentEmojis', value: recents.splice(0, 16) }); - - this.$emit('chosen', getKey(emoji)); - } - } -}); -</script> - -<style lang="stylus" scoped> -.prlncendiewqqkrevzeruhndoakghvtx - width 350px - background var(--face) - - > header - display flex - - > button - flex 1 - padding 10px 0 - font-size 16px - color var(--text) - transition color 0.2s ease - - &:hover - color var(--textHighlighted) - transition color 0s - - &.active - color var(--primary) - transition color 0s - - > .emojis - height 300px - overflow-y auto - overflow-x hidden - - > header.category - position sticky - top 0 - left 0 - z-index 1 - padding 8px - background var(--faceHeader) - color var(--text) - font-size 12px - - >>> header.sub - padding 4px 8px - color var(--text) - font-size 12px - - >>> div.list - display grid - grid-template-columns 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr - gap 4px - padding 8px - - > button - padding 0 - width 100% - - &:before - content '' - display block - width 1px - height 0 - padding-bottom 100% - - &:hover - > * - transform scale(1.2) - transition transform 0s - - > * - position absolute - top 0 - left 0 - width 100% - height 100% - object-fit contain - font-size 28px - transition transform 0.2s ease - pointer-events none - -</style> diff --git a/src/client/app/common/views/components/error.vue b/src/client/app/common/views/components/error.vue deleted file mode 100644 index 0462a6efda..0000000000 --- a/src/client/app/common/views/components/error.vue +++ /dev/null @@ -1,28 +0,0 @@ -<template> -<div class="wjqjnyhzogztorhrdgcpqlkxhkmuetgj"> - <p><fa icon="exclamation-triangle"/> {{ $t('@.error.title') }}</p> - <ui-button @click="() => $emit('retry')">{{ $t('@.error.retry') }}</ui-button> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n() -}); -</script> - -<style lang="stylus" scoped> -.wjqjnyhzogztorhrdgcpqlkxhkmuetgj - max-width 350px - margin 0 auto - padding 32px - text-align center - color var(--text) - - > p - margin 0 0 8px 0 - -</style> diff --git a/src/client/app/common/views/components/file-type-icon.vue b/src/client/app/common/views/components/file-type-icon.vue deleted file mode 100644 index 3a9fe768d1..0000000000 --- a/src/client/app/common/views/components/file-type-icon.vue +++ /dev/null @@ -1,17 +0,0 @@ -<template> -<span class="mk-file-type-icon"> - <template v-if="kind == 'image'"><fa icon="file-image"/></template> -</span> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - props: ['type'], - computed: { - kind(): string { - return this.type.split('/')[0]; - } - } -}); -</script> diff --git a/src/client/app/common/views/components/follow-button.vue b/src/client/app/common/views/components/follow-button.vue deleted file mode 100644 index 074a0c05b6..0000000000 --- a/src/client/app/common/views/components/follow-button.vue +++ /dev/null @@ -1,209 +0,0 @@ -<template> -<button class="wfliddvnhxvyusikowhxozkyxyenqxqr" - :class="{ wait, block, inline, mini, transparent, active: isFollowing || hasPendingFollowRequestFromYou }" - @click="onClick" - :disabled="wait" - :inline="inline" -> - <template v-if="!wait"> - <fa :icon="iconAndText[0]"/> <template v-if="!mini">{{ iconAndText[1] }}</template> - </template> - <template v-else><fa icon="spinner" pulse fixed-width/></template> -</button> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('common/views/components/follow-button.vue'), - - props: { - user: { - type: Object, - required: true - }, - block: { - type: Boolean, - required: false, - default: false - }, - inline: { - type: Boolean, - required: false, - default: false - }, - mini: { - type: Boolean, - required: false, - default: false - }, - transparent: { - type: Boolean, - required: false, - default: true - }, - }, - - data() { - return { - isFollowing: this.user.isFollowing, - hasPendingFollowRequestFromYou: this.user.hasPendingFollowRequestFromYou, - wait: false, - connection: null - }; - }, - - computed: { - iconAndText(): any[] { - return ( - (this.hasPendingFollowRequestFromYou && this.user.isLocked) ? ['hourglass-half', this.$t('request-pending')] : - (this.hasPendingFollowRequestFromYou && !this.user.isLocked) ? ['spinner', this.$t('follow-processing')] : - (this.isFollowing) ? ['minus', this.$t('following')] : - (!this.isFollowing && this.user.isLocked) ? ['plus', this.$t('follow-request')] : - (!this.isFollowing && !this.user.isLocked) ? ['plus', this.$t('follow')] : - [] - ); - } - }, - - mounted() { - this.connection = this.$root.stream.useSharedConnection('main'); - - this.connection.on('follow', this.onFollowChange); - this.connection.on('unfollow', this.onFollowChange); - }, - - beforeDestroy() { - this.connection.dispose(); - }, - - methods: { - onFollowChange(user) { - if (user.id == this.user.id) { - this.isFollowing = user.isFollowing; - this.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; - } - }, - - async onClick() { - this.wait = true; - - try { - if (this.isFollowing) { - const { canceled } = await this.$root.dialog({ - type: 'warning', - text: this.$t('@.unfollow-confirm', { name: this.user.name || this.user.username }), - showCancelButton: true - }); - - if (canceled) return; - - await this.$root.api('following/delete', { - userId: this.user.id - }); - } else { - if (this.hasPendingFollowRequestFromYou) { - await this.$root.api('following/requests/cancel', { - userId: this.user.id - }); - } else if (this.user.isLocked) { - await this.$root.api('following/create', { - userId: this.user.id - }); - this.hasPendingFollowRequestFromYou = true; - } else { - await this.$root.api('following/create', { - userId: this.user.id - }); - this.hasPendingFollowRequestFromYou = true; - } - } - } catch (e) { - console.error(e); - } finally { - this.wait = false; - } - } - } -}); -</script> - -<style lang="stylus" scoped> -.wfliddvnhxvyusikowhxozkyxyenqxqr - display block - user-select none - cursor pointer - padding 0 16px - margin 0 - min-width 100px - line-height 36px - font-size 14px - font-weight bold - color var(--primary) - background transparent - outline none - border solid 1px var(--primary) - border-radius 36px - - &:not(.transparent) - background #fff - - &.inline - display inline-block - - &.mini - padding 0 - min-width 0 - width 32px - height 32px - font-size 16px - border-radius 4px - line-height 32px - - &:focus - &:after - border-radius 8px - - &.block - width 100% - - &:focus - &:after - content "" - pointer-events none - position absolute - top -5px - right -5px - bottom -5px - left -5px - border 2px solid var(--primaryAlpha03) - border-radius 36px - - &:hover - background var(--primaryAlpha01) - - &:active - background var(--primaryAlpha02) - - &.active - color var(--primaryForeground) - background var(--primary) - - &:hover - background var(--primaryLighten10) - border-color var(--primaryLighten10) - - &:active - background var(--primaryDarken10) - border-color var(--primaryDarken10) - - &.wait - cursor wait !important - opacity 0.7 - - * - pointer-events none - -</style> diff --git a/src/client/app/common/views/components/forkit.vue b/src/client/app/common/views/components/forkit.vue deleted file mode 100644 index 328e3ca7b0..0000000000 --- a/src/client/app/common/views/components/forkit.vue +++ /dev/null @@ -1,48 +0,0 @@ -<template> -<a class="a" :href="repositoryUrl" rel="noopener" target="_blank" title="View source on GitHub"> - <svg width="80" height="80" viewBox="0 0 250 250" aria-hidden="aria-hidden"> - <path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path> - <path class="octo-arm" d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor"></path> - <path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor"></path> - </svg> -</a> -</template> - -<script lang="ts"> -import Vue from 'vue' -export default Vue.extend({ - data() { - return { - repositoryUrl: 'https://github.com/syuilo/misskey' - }; - } -}); -</script> - -<style lang="stylus" scoped> -.a - display block - - > svg - display block - //fill #151513 - //color #fff - fill var(--primary) - color var(--primaryForeground) - - .octo-arm - transform-origin 130px 106px - - &:hover - .octo-arm - animation octocat-wave 560ms ease-in-out - - @keyframes octocat-wave - 0%, 100% - transform rotate(0) - 20%, 60% - transform rotate(-25deg) - 40%, 80% - transform rotate(10deg) - -</style> diff --git a/src/client/app/common/views/components/frac.vue b/src/client/app/common/views/components/frac.vue deleted file mode 100644 index 1840bd28fe..0000000000 --- a/src/client/app/common/views/components/frac.vue +++ /dev/null @@ -1,49 +0,0 @@ -<template> -<span class="mk-frac"><span>{{ pad }}</span><span>{{ value }} / {{ total }}</span></span> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n(), - props: { - value: { - type: Number, - required: true, - }, - total: { - type: Number, - required: true, - }, - }, - computed: { - pad(this: { - value: number; - total: number; - length(value: number): number; - }) { - return '0'.repeat(this.length(this.total) - this.length(this.value)); - }, - }, - methods: { - length(value: number) { - const string = value.toString(); - - return string.includes('e') ? -~string.substr(string.indexOf('e')) : string.length; - }, - }, -}); -</script> - -<style lang="stylus" scoped> -.mk-frac - -webkit-font-feature-settings 'tnum' - -moz-font-feature-settings 'tnum' - font-feature-settings 'tnum' - font-variant-numeric tabular-nums - - > :first-child - visibility hidden -</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 deleted file mode 100644 index a7c918aa71..0000000000 --- a/src/client/app/common/views/components/games/reversi/reversi.game.vue +++ /dev/null @@ -1,473 +0,0 @@ -<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"><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"> - <mfm :key="'turn:' + $options.filters.userName(turnUser)" :text="$t('@.reversi.turn-of', { name: $options.filters.userName(turnUser) })" :plain="true" :custom-emojis="turnUser.emojis"/> - <mk-ellipsis/> - </p> - <p class="turn" v-if="logPos != logs.length"> - <mfm :key="'past-turn-of:' + $options.filters.userName(turnUser)" :text="$t('@.reversi.past-turn-of', { name: $options.filters.userName(turnUser) })" :plain="true" :custom-emojis="turnUser.emojis"/> - </p> - <p class="turn1" v-if="iAmPlayer && !game.isEnded && !isMyTurn">{{ $t('@.reversi.opponent-turn') }}<mk-ellipsis/></p> - <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"> - <mfm :key="'won'" :text="$t('@.reversi.won', { name: $options.filters.userName(game.winner) })" :plain="true" :custom-emojis="game.winner.emojis"/> - <span v-if="game.surrendered != null"> ({{ $t('surrendered') }})</span> - </template> - <template v-else>{{ $t('@.reversi.drawn') }}</template> - </p> - </div> - - <div class="board"> - <div class="labels-x" v-if="$store.state.settings.gamesReversiShowBoardLabels"> - <span v-for="i in game.map[0].length">{{ String.fromCharCode(64 + i) }}</span> - </div> - <div class="flex"> - <div class="labels-y" v-if="$store.state.settings.gamesReversiShowBoardLabels"> - <div v-for="i in game.map.length">{{ i }}</div> - </div> - <div class="cells" :style="cellsStyle"> - <div v-for="(stone, i) in o.board" - :class="{ empty: stone == null, none: o.map[i] == 'null', isEnded: game.isEnded, myTurn: !game.isEnded && isMyTurn, can: turnUser ? o.canPut(turnUser.id == blackUser.id, i) : null, prev: o.prevPos == i }" - @click="set(i)" - :title="`${String.fromCharCode(65 + o.transformPosToXy(i)[0])}${o.transformPosToXy(i)[1] + 1}`"> - <template v-if="$store.state.settings.gamesReversiUseAvatarStones"> - <img v-if="stone === true" :src="blackUser.avatarUrl" alt="black"> - <img v-if="stone === false" :src="whiteUser.avatarUrl" alt="white"> - </template> - <template v-else> - <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.gamesReversiShowBoardLabels"> - <div v-for="i in game.map.length">{{ i }}</div> - </div> - </div> - <div class="labels-x" v-if="this.$store.state.settings.gamesReversiShowBoardLabels"> - <span v-for="i in game.map[0].length">{{ String.fromCharCode(64 + i) }}</span> - </div> - </div> - - <p class="status"><b>{{ $t('@.reversi.this-turn', { count: logPos }) }}</b> {{ $t('@.reversi.black') }}:{{ o.blackCount }} {{ $t('@.reversi.white') }}:{{ o.whiteCount }} {{ $t('@.reversi.total') }}:{{ o.blackCount + o.whiteCount }}</p> - - <div class="actions" v-if="!game.isEnded && iAmPlayer"> - <form-button @click="surrender">{{ $t('surrender') }}</form-button> - </div> - - <div class="player" v-if="game.isEnded"> - <span>{{ logPos }} / {{ logs.length }}</span> - <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"> - <p v-if="game.isLlotheo">{{ $t('is-llotheo') }}</p> - <p v-if="game.loopedBoard">{{ $t('looped-map') }}</p> - <p v-if="game.canPutEverywhere">{{ $t('can-put-everywhere') }}</p> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -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'), - props: { - initGame: { - type: Object, - require: true - }, - connection: { - type: Object, - require: true - }, - selfNav: { - type: Boolean, - require: true - } - }, - - data() { - return { - game: null, - o: null as Reversi, - logs: [], - logPos: 0, - pollingClock: null, - faAngleDoubleLeft, faAngleLeft, faAngleRight, faAngleDoubleRight, fasCircle, farCircle - }; - }, - - computed: { - iAmPlayer(): boolean { - if (!this.$store.getters.isSignedIn) return false; - return this.game.user1Id == this.$store.state.i.id || this.game.user2Id == this.$store.state.i.id; - }, - - myColor(): Color { - if (!this.iAmPlayer) return null; - if (this.game.user1Id == this.$store.state.i.id && this.game.black == 1) return true; - if (this.game.user2Id == this.$store.state.i.id && this.game.black == 2) return true; - return false; - }, - - opColor(): Color { - if (!this.iAmPlayer) return null; - return this.myColor === true ? false : true; - }, - - blackUser(): any { - return this.game.black == 1 ? this.game.user1 : this.game.user2; - }, - - whiteUser(): any { - return this.game.black == 1 ? this.game.user2 : this.game.user1; - }, - - turnUser(): any { - if (this.o.turn === true) { - return this.game.black == 1 ? this.game.user1 : this.game.user2; - } else if (this.o.turn === false) { - return this.game.black == 1 ? this.game.user2 : this.game.user1; - } else { - return null; - } - }, - - isMyTurn(): boolean { - if (!this.iAmPlayer) return false; - if (this.turnUser == null) return false; - return this.turnUser.id == this.$store.state.i.id; - }, - - cellsStyle(): any { - return { - 'grid-template-rows': `repeat(${this.game.map.length}, 1fr)`, - 'grid-template-columns': `repeat(${this.game.map[0].length}, 1fr)` - }; - } - }, - - watch: { - logPos(v) { - if (!this.game.isEnded) return; - this.o = new Reversi(this.game.map, { - isLlotheo: this.game.isLlotheo, - canPutEverywhere: this.game.canPutEverywhere, - loopedBoard: this.game.loopedBoard - }); - for (const log of this.logs.slice(0, v)) { - this.o.put(log.color, log.pos); - } - this.$forceUpdate(); - } - }, - - created() { - this.game = this.initGame; - - this.o = new Reversi(this.game.map, { - isLlotheo: this.game.isLlotheo, - canPutEverywhere: this.game.canPutEverywhere, - loopedBoard: this.game.loopedBoard - }); - - for (const log of this.game.logs) { - this.o.put(log.color, log.pos); - } - - this.logs = this.game.logs; - this.logPos = this.logs.length; - - // 通信を取りこぼしてもいいように定期的にポーリングさせる - if (this.game.isStarted && !this.game.isEnded) { - this.pollingClock = setInterval(() => { - if (this.game.isEnded) return; - const crc32 = CRC32.str(this.logs.map(x => x.pos.toString()).join('')); - this.connection.send('check', { - crc32: crc32 - }); - }, 3000); - } - }, - - mounted() { - this.connection.on('set', this.onSet); - this.connection.on('rescue', this.onRescue); - this.connection.on('ended', this.onEnded); - }, - - beforeDestroy() { - this.connection.off('set', this.onSet); - this.connection.off('rescue', this.onRescue); - this.connection.off('ended', this.onEnded); - - clearInterval(this.pollingClock); - }, - - methods: { - set(pos) { - if (this.game.isEnded) return; - if (!this.iAmPlayer) return; - if (!this.isMyTurn) return; - if (!this.o.canPut(this.myColor, pos)) return; - - this.o.put(this.myColor, pos); - - // サウンドを再生する - if (this.$store.state.device.enableSounds) { - const sound = new Audio(`${url}/assets/reversi-put-me.mp3`); - sound.volume = this.$store.state.device.soundVolume; - sound.play(); - } - - this.connection.send('set', { - pos: pos - }); - - this.checkEnd(); - - this.$forceUpdate(); - }, - - onSet(x) { - this.logs.push(x); - this.logPos++; - this.o.put(x.color, x.pos); - this.checkEnd(); - this.$forceUpdate(); - - // サウンドを再生する - if (this.$store.state.device.enableSounds && x.color != this.myColor) { - const sound = new Audio(`${url}/assets/reversi-put-you.mp3`); - sound.volume = this.$store.state.device.soundVolume; - sound.play(); - } - }, - - onEnded(x) { - this.game = x.game; - }, - - checkEnd() { - this.game.isEnded = this.o.isEnded; - if (this.game.isEnded) { - if (this.o.winner === true) { - this.game.winnerId = this.game.black == 1 ? this.game.user1Id : this.game.user2Id; - this.game.winner = this.game.black == 1 ? this.game.user1 : this.game.user2; - } else if (this.o.winner === false) { - this.game.winnerId = this.game.black == 1 ? this.game.user2Id : this.game.user1Id; - this.game.winner = this.game.black == 1 ? this.game.user2 : this.game.user1; - } else { - this.game.winnerId = null; - this.game.winner = null; - } - } - }, - - // 正しいゲーム情報が送られてきたとき - onRescue(game) { - this.game = game; - - this.o = new Reversi(this.game.map, { - isLlotheo: this.game.isLlotheo, - canPutEverywhere: this.game.canPutEverywhere, - loopedBoard: this.game.loopedBoard - }); - - for (const log of this.game.logs) { - this.o.put(log.color, log.pos, true); - } - - this.logs = this.game.logs; - this.logPos = this.logs.length; - - this.checkEnd(); - this.$forceUpdate(); - }, - - surrender() { - this.$root.api('games/reversi/games/surrender', { - gameId: this.game.id - }); - }, - - goIndex() { - this.$emit('go-index'); - } - } -}); -</script> - -<style lang="stylus" scoped> -.xqnhankfuuilcwvhgsopeqncafzsquya - text-align center - - > .go-index - position absolute - top 0 - left 0 - z-index 1 - width 42px - height 42px - - > header - padding 8px - border-bottom dashed 1px var(--reversiGameHeaderLine) - - a - color inherit - - > .board - width calc(100% - 16px) - max-width 500px - margin 0 auto - - $label-size = 16px - $gap = 4px - - > .labels-x - height $label-size - padding 0 $label-size - display flex - - > * - flex 1 - display flex - align-items center - justify-content center - font-size 12px - - &:first-child - margin-left -($gap / 2) - - &:last-child - margin-right -($gap / 2) - - > .flex - display flex - - > .labels-y - width $label-size - display flex - flex-direction column - - > * - flex 1 - display flex - align-items center - justify-content center - font-size 12px - - &:first-child - margin-top -($gap / 2) - - &:last-child - margin-bottom -($gap / 2) - - > .cells - flex 1 - display grid - grid-gap $gap - - > div - background transparent - border-radius 6px - overflow hidden - - * - pointer-events none - user-select none - - &.empty - border solid 2px var(--reversiGameEmptyCell) - - &.empty.can - background var(--reversiGameEmptyCell) - - &.empty.myTurn - border-color var(--reversiGameEmptyCellMyTurn) - - &.can - background var(--reversiGameEmptyCellCanPut) - cursor pointer - - &:hover - border-color var(--primaryDarken10) - background var(--primary) - - &:active - background var(--primaryDarken10) - - &.prev - box-shadow 0 0 0 4px var(--primaryAlpha07) - - &.isEnded - border-color var(--reversiGameEmptyCellMyTurn) - - &.none - border-color transparent !important - - > svg - display block - width 100% - height 100% - - > img - display block - width 100% - height 100% - - > .graph - display grid - grid-template-columns repeat(61, 1fr) - width 300px - height 38px - margin 0 auto 16px auto - - > div - &:not(:empty) - background #ccc - - > div:first-child - background #333 - - > div:last-child - background #ccc - - > .status - margin 0 - padding 16px 0 - - > .actions - padding-bottom 16px - - > .player - padding 0 16px 32px 16px - margin 0 auto - max-width 500px - - > span - display inline-block - margin 0 8px - min-width 70px - -</style> diff --git a/src/client/app/common/views/components/games/reversi/reversi.gameroom.vue b/src/client/app/common/views/components/games/reversi/reversi.gameroom.vue deleted file mode 100644 index 4099389502..0000000000 --- a/src/client/app/common/views/components/games/reversi/reversi.gameroom.vue +++ /dev/null @@ -1,56 +0,0 @@ -<template> -<div> - <x-room v-if="!g.isStarted" :game="g" :connection="connection"/> - <x-game v-else :init-game="g" :connection="connection" :self-nav="selfNav" @go-index="goIndex"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../../i18n'; -import XGame from './reversi.game.vue'; -import XRoom from './reversi.room.vue'; - -export default Vue.extend({ - i18n: i18n('common/views/components/games/reversi/reversi.gameroom.vue'), - components: { - XGame, - XRoom - }, - props: { - game: { - type: Object, - required: true - }, - selfNav: { - type: Boolean, - require: true - } - }, - data() { - return { - connection: null, - g: null - }; - }, - created() { - this.g = this.game; - this.connection = this.$root.stream.connectToChannel('gamesReversiGame', { - gameId: this.game.id - }); - this.connection.on('started', this.onStarted); - }, - beforeDestroy() { - this.connection.dispose(); - }, - methods: { - onStarted(game) { - Object.assign(this.g, game); - this.$forceUpdate(); - }, - goIndex() { - this.$emit('go-index'); - } - } -}); -</script> 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 deleted file mode 100644 index 94e1d9a7e3..0000000000 --- a/src/client/app/common/views/components/games/reversi/reversi.index.vue +++ /dev/null @@ -1,245 +0,0 @@ -<template> -<div class="phgnkghfpyvkrvwiajkiuoxyrdaqpzcx"> - <h1>{{ $t('title') }}</h1> - <p>{{ $t('sub-title') }}</p> - <div class="play"> - <form-button primary round @click="match">{{ $t('invite') }}</form-button> - <details> - <summary>{{ $t('rule') }}</summary> - <div> - <p>{{ $t('rule-desc') }}</p> - <dl> - <dt><b>{{ $t('mode-invite') }}</b></dt> - <dd>{{ $t('mode-invite-desc') }}</dd> - </dl> - </div> - </details> - </div> - <section v-if="invitations.length > 0"> - <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><mk-user-name :user="i.parent"/></b></span> - <span class="username">@{{ i.parent.username }}</span> - <mk-time :time="i.createdAt"/> - </div> - </section> - <section v-if="myGames.length > 0"> - <h2>{{ $t('my-games') }}</h2> - <a class="game" v-for="g in myGames" tabindex="-1" @click.prevent="go(g)" :href="`/games/reversi/${g.id}`"> - <mk-avatar class="avatar" :user="g.user1"/> - <mk-avatar class="avatar" :user="g.user2"/> - <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> - </section> - <section v-if="games.length > 0"> - <h2>{{ $t('all-games') }}</h2> - <a class="game" v-for="g in games" tabindex="-1" @click.prevent="go(g)" :href="`/games/reversi/${g.id}`"> - <mk-avatar class="avatar" :user="g.user1"/> - <mk-avatar class="avatar" :user="g.user2"/> - <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> - </section> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../../i18n'; - -export default Vue.extend({ - i18n: i18n('common/views/components/games/reversi/reversi.index.vue'), - data() { - return { - games: [], - gamesFetching: true, - gamesMoreFetching: false, - myGames: [], - matching: null, - invitations: [], - connection: null - }; - }, - - mounted() { - if (this.$store.getters.isSignedIn) { - this.connection = this.$root.stream.useSharedConnection('gamesReversi'); - - this.connection.on('invited', this.onInvited); - - this.$root.api('games/reversi/games', { - my: true - }).then(games => { - this.myGames = games; - }); - - this.$root.api('games/reversi/invitations').then(invitations => { - this.invitations = this.invitations.concat(invitations); - }); - } - - this.$root.api('games/reversi/games').then(games => { - this.games = games; - this.gamesFetching = false; - }); - }, - - beforeDestroy() { - if (this.connection) { - this.connection.dispose(); - } - }, - - methods: { - go(game) { - this.$emit('go', game); - }, - - 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); - } - }); - }, - - accept(invitation) { - this.$root.api('games/reversi/match', { - userId: invitation.parent.id - }).then(game => { - if (game) { - this.$emit('go', game); - } - }); - }, - - onInvited(invite) { - this.invitations.unshift(invite); - } - } -}); -</script> - -<style lang="stylus" scoped> -.phgnkghfpyvkrvwiajkiuoxyrdaqpzcx - > h1 - margin 0 - padding 24px - font-size 24px - text-align center - font-weight normal - color #fff - background linear-gradient(to bottom, var(--reversiBannerGradientStart), var(--reversiBannerGradientEnd)) - - & + p - margin 0 - padding 12px - margin-bottom 12px - text-align center - font-size 14px - border-bottom solid 1px var(--faceDivider) - - > .play - margin 0 auto - padding 0 16px - max-width 500px - text-align center - - > details - margin 8px 0 - - > div - padding 16px - font-size 14px - text-align left - background var(--reversiDescBg) - border-radius 8px - - > section - margin 0 auto - padding 0 16px 16px 16px - max-width 500px - border-top solid 1px var(--faceDivider) - - > h2 - margin 0 - padding 16px 0 8px 0 - font-size 16px - font-weight bold - - .invitation - margin 8px 0 - padding 8px - color var(--text) - background var(--face) - box-shadow 0 2px 16px var(--reversiListItemShadow) - border-radius 6px - cursor pointer - - * - pointer-events none - user-select none - - &:focus - border-color var(--primary) - - &:hover - box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05) - - &:active - box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1) - - > .avatar - width 32px - height 32px - border-radius 100% - - > span - margin 0 8px - line-height 32px - - .game - display block - margin 8px 0 - padding 8px - color var(--text) - background var(--face) - box-shadow 0 2px 16px var(--reversiListItemShadow) - border-radius 6px - cursor pointer - - * - pointer-events none - user-select none - - &:hover - box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05) - - &:active - box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1) - - > .avatar - width 32px - height 32px - border-radius 100% - - > span - margin 0 8px - line-height 32px - -</style> 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 deleted file mode 100644 index c1657f49e5..0000000000 --- a/src/client/app/common/views/components/games/reversi/reversi.room.vue +++ /dev/null @@ -1,355 +0,0 @@ -<template> -<div class="urbixznjwwuukfsckrwzwsqzsxornqij"> - <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> - - <div class="card map"> - <header> - <select v-model="mapName" :placeholder="$t('choose-map')" @change="onMapChange"> - <option label="-Custom-" :value="mapName" v-if="mapName == '-Custom-'"/> - <option :label="$t('random')" :value="null"/> - <optgroup v-for="c in mapCategories" :key="c" :label="c"> - <option v-for="m in maps" v-if="m.category == c" :key="m.name" :label="m.name" :value="m.name">{{ m.name }}</option> - </optgroup> - </select> - </header> - - <div> - <div class="random" v-if="game.map == null"><fa icon="dice"/></div> - <div class="board" v-else :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }"> - <div v-for="(x, i) in game.map.join('')" - :data-none="x == ' '" - @click="onPixelClick(i, x)"> - <fa v-if="x == 'b'" :icon="fasCircle"/> - <fa v-if="x == 'w'" :icon="farCircle"/> - </div> - </div> - </div> - </div> - - <div class="card"> - <header> - <span>{{ $t('black-or-white') }}</span> - </header> - - <div> - <form-radio v-model="game.bw" value="random" @change="updateSettings('bw')">{{ $t('random') }}</form-radio> - <form-radio v-model="game.bw" :value="1" @change="updateSettings('bw')">{{ 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.bw" :value="2" @change="updateSettings('bw')">{{ this.$t('black-is').split('{}')[0] }}<b><mk-user-name :user="game.user2"/></b>{{ this.$t('black-is').split('{}')[1] }}</form-radio> - </div> - </div> - - <div class="card"> - <header> - <span>{{ $t('rules') }}</span> - </header> - - <div> - <ui-switch v-model="game.isLlotheo" @change="updateSettings('isLlotheo')">{{ $t('is-llotheo') }}</ui-switch> - <ui-switch v-model="game.loopedBoard" @change="updateSettings('loopedBoard')">{{ $t('looped-map') }}</ui-switch> - <ui-switch v-model="game.canPutEverywhere" @change="updateSettings('canPutEverywhere')">{{ $t('can-put-everywhere') }}</ui-switch> - </div> - </div> - - <div class="card form" v-if="form"> - <header> - <span>{{ $t('settings-of-the-bot') }}</span> - </header> - - <div> - <template v-for="item in form"> - <ui-switch v-if="item.type == 'switch'" v-model="item.value" :key="item.id" @change="onChangeForm(item)">{{ item.label || item.desc || '' }}</ui-switch> - - <div class="card" v-if="item.type == 'radio'" :key="item.id"> - <header> - <span>{{ item.label }}</span> - </header> - - <div> - <form-radio v-for="(r, i) in item.items" :key="item.id + ':' + i" v-model="item.value" :value="r.value" @change="onChangeForm(item)">{{ r.label }}</form-radio> - </div> - </div> - - <div class="card" v-if="item.type == 'slider'" :key="item.id"> - <header> - <span>{{ item.label }}</span> - </header> - - <div> - <input type="range" :min="item.min" :max="item.max" :step="item.step || 1" v-model="item.value" @change="onChangeForm(item)"/> - </div> - </div> - - <div class="card" v-if="item.type == 'textbox'" :key="item.id"> - <header> - <span>{{ item.label }}</span> - </header> - - <div> - <input v-model="item.value" @change="onChangeForm(item)"/> - </div> - </div> - </template> - </div> - </div> - </div> - - <footer> - <p class="status"> - <template v-if="isAccepted && isOpAccepted">{{ $t('this-game-is-started-soon') }}<mk-ellipsis/></template> - <template v-if="isAccepted && !isOpAccepted">{{ $t('waiting-for-other') }}<mk-ellipsis/></template> - <template v-if="!isAccepted && isOpAccepted">{{ $t('waiting-for-me') }}</template> - <template v-if="!isAccepted && !isOpAccepted">{{ $t('waiting-for-both') }}<mk-ellipsis/></template> - </p> - - <div class="actions"> - <form-button @click="exit">{{ $t('cancel') }}</form-button> - <form-button primary @click="accept" v-if="!isAccepted">{{ $t('ready') }}</form-button> - <form-button primary @click="cancel" v-if="isAccepted">{{ $t('cancel-ready') }}</form-button> - </div> - </footer> -</div> -</template> - -<script lang="ts"> -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'), - props: ['game', 'connection'], - - data() { - return { - o: null, - isLlotheo: false, - mapName: maps.eighteight.name, - maps: maps, - form: null, - messages: [], - fasCircle, farCircle - }; - }, - - computed: { - mapCategories(): string[] { - const categories = Object.values(maps).map(x => x.category); - return categories.filter((item, pos) => categories.indexOf(item) == pos); - }, - isAccepted(): boolean { - if (this.game.user1Id == this.$store.state.i.id && this.game.user1Accepted) return true; - if (this.game.user2Id == this.$store.state.i.id && this.game.user2Accepted) return true; - return false; - }, - isOpAccepted(): boolean { - if (this.game.user1Id != this.$store.state.i.id && this.game.user1Accepted) return true; - if (this.game.user2Id != this.$store.state.i.id && this.game.user2Accepted) return true; - return false; - } - }, - - created() { - this.connection.on('changeAccepts', this.onChangeAccepts); - this.connection.on('updateSettings', this.onUpdateSettings); - this.connection.on('initForm', this.onInitForm); - this.connection.on('message', this.onMessage); - - if (this.game.user1Id != this.$store.state.i.id && this.game.form1) this.form = this.game.form1; - if (this.game.user2Id != this.$store.state.i.id && this.game.form2) this.form = this.game.form2; - }, - - beforeDestroy() { - this.connection.off('changeAccepts', this.onChangeAccepts); - this.connection.off('updateSettings', this.onUpdateSettings); - this.connection.off('initForm', this.onInitForm); - this.connection.off('message', this.onMessage); - }, - - methods: { - exit() { - - }, - - accept() { - this.connection.send('accept', {}); - }, - - cancel() { - this.connection.send('cancelAccept', {}); - }, - - onChangeAccepts(accepts) { - this.game.user1Accepted = accepts.user1; - this.game.user2Accepted = accepts.user2; - this.$forceUpdate(); - }, - - updateSettings(key: string) { - this.connection.send('updateSettings', { - key: key, - value: this.game[key] - }); - }, - - onUpdateSettings({ key, value }) { - this.game[key] = value; - if (this.game.map == null) { - this.mapName = null; - } else { - const found = Object.values(maps).find(x => x.data.join('') == this.game.map.join('')); - this.mapName = found ? found.name : '-Custom-'; - } - }, - - onInitForm(x) { - if (x.userId == this.$store.state.i.id) return; - this.form = x.form; - }, - - onMessage(x) { - if (x.userId == this.$store.state.i.id) return; - this.messages.unshift(x.message); - }, - - onChangeForm(item) { - this.connection.send('updateForm', { - id: item.id, - value: item.value - }); - }, - - onMapChange() { - if (this.mapName == null) { - this.game.map = null; - } else { - this.game.map = Object.values(maps).find(x => x.name == this.mapName).data; - } - this.$forceUpdate(); - this.updateSettings('map'); - }, - - onPixelClick(pos, pixel) { - const x = pos % this.game.map[0].length; - const y = Math.floor(pos / this.game.map[0].length); - const newPixel = - pixel == ' ' ? '-' : - pixel == '-' ? 'b' : - pixel == 'b' ? 'w' : - ' '; - const line = this.game.map[y].split(''); - line[x] = newPixel; - this.$set(this.game.map, y, line.join('')); - this.$forceUpdate(); - this.updateSettings('map'); - } - } -}); -</script> - -<style lang="stylus" scoped> -.urbixznjwwuukfsckrwzwsqzsxornqij - text-align center - background var(--bg) - - > header - padding 8px - border-bottom dashed 1px #c4cdd4 - - > div - padding 0 16px - - > .card - margin 0 auto 16px auto - - &.map - > header - > select - width 100% - padding 12px 14px - background var(--face) - border 1px solid var(--reversiMapSelectBorder) - border-radius 4px - color var(--text) - cursor pointer - transition border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1) - -webkit-appearance none - -moz-appearance none - appearance none - - &:hover - border-color var(--reversiMapSelectHoverBorder) - - &:focus - &:active - border-color var(--primary) - - > div - > .random - padding 32px 0 - font-size 64px - color var(--text) - opacity 0.7 - - > .board - display grid - grid-gap 4px - width 300px - height 300px - margin 0 auto - color var(--text) - - > div - background transparent - border solid 2px var(--faceDivider) - border-radius 6px - overflow hidden - cursor pointer - - * - pointer-events none - user-select none - width 100% - height 100% - - &[data-none] - border-color transparent - - &.form - > div - > .card + .card - margin-top 16px - - input[type='range'] - width 100% - - .card - max-width 400px - border-radius 4px - background var(--face) - color var(--text) - box-shadow 0 2px 12px 0 var(--reversiRoomFormShadow) - - > header - padding 18px 20px - border-bottom 1px solid var(--faceDivider) - - > div - padding 20px - color var(--text) - - > footer - position sticky - bottom 0 - padding 16px - background var(--reversiRoomFooterBg) - border-top solid 1px var(--faceDivider) - - > .status - margin 0 0 16px 0 - -</style> diff --git a/src/client/app/common/views/components/games/reversi/reversi.vue b/src/client/app/common/views/components/games/reversi/reversi.vue deleted file mode 100644 index d33471a049..0000000000 --- a/src/client/app/common/views/components/games/reversi/reversi.vue +++ /dev/null @@ -1,175 +0,0 @@ -<template> -<div class="vchtoekanapleubgzioubdtmlkribzfd"> - <div v-if="game"> - <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><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> - </div> - <div v-else-if="gameId"> - ... - </div> - <div class="index" v-else> - <x-index @go="nav" @matching="onMatching"/> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../../i18n'; -import XGameroom from './reversi.gameroom.vue'; -import XIndex from './reversi.index.vue'; -import Progress from '../../../../scripts/loading'; - -export default Vue.extend({ - i18n: i18n('common/views/components/games/reversi/reversi.vue'), - components: { - XGameroom, - XIndex - }, - - props: { - gameId: { - type: String, - required: false - }, - selfNav: { - type: Boolean, - require: false, - default: true - } - }, - - data() { - return { - game: null, - matching: null, - connection: null, - pingClock: null - }; - }, - - watch: { - game() { - this.$emit('gamed', this.game); - }, - - gameId() { - this.fetch(); - } - }, - - mounted() { - this.fetch(); - - if (this.$store.getters.isSignedIn) { - this.connection = this.$root.stream.useSharedConnection('gamesReversi'); - - this.connection.on('matched', this.onMatched); - - this.pingClock = setInterval(() => { - if (this.matching) { - this.connection.send('ping', { - id: this.matching.id - }); - } - }, 3000); - } - }, - - beforeDestroy() { - if (this.connection) { - this.connection.dispose(); - clearInterval(this.pingClock); - } - }, - - methods: { - fetch() { - if (this.gameId == null) { - this.game = null; - } else { - Progress.start(); - this.$root.api('games/reversi/games/show', { - gameId: this.gameId - }).then(game => { - this.game = game; - Progress.done(); - }); - } - }, - - async nav(game, actualNav = true) { - if (this.selfNav) { - // 受け取ったゲーム情報が省略されたものなら完全な情報を取得する - if (game != null && game.map == null) { - game = await this.$root.api('games/reversi/games/show', { - gameId: game.id - }); - } - - this.game = game; - } else { - this.$emit('nav', game, actualNav); - } - }, - - onMatching(user) { - this.matching = user; - }, - - cancel() { - this.matching = null; - this.$root.api('games/reversi/match/cancel'); - }, - - accept(invitation) { - this.$root.api('games/reversi/match', { - userId: invitation.parent.id - }).then(game => { - if (game) { - this.matching = null; - - this.nav(game); - } - }); - }, - - onMatched(game) { - this.matching = null; - this.game = game; - this.nav(game, false); - }, - - goIndex() { - this.nav(null); - } - } -}); -</script> - -<style lang="stylus" scoped> -.vchtoekanapleubgzioubdtmlkribzfd - color var(--text) - background var(--bg) - - > .matching - > h1 - margin 0 - padding 24px - font-size 20px - text-align center - font-weight normal - - > .cancel - margin 0 auto - padding 24px 0 0 0 - max-width 200px - text-align center - border-top dashed 1px #c4cdd4 - -</style> diff --git a/src/client/app/common/views/components/google.vue b/src/client/app/common/views/components/google.vue deleted file mode 100644 index 1e88147399..0000000000 --- a/src/client/app/common/views/components/google.vue +++ /dev/null @@ -1,66 +0,0 @@ -<template> -<div class="mk-google"> - <input type="search" v-model="query" :placeholder="q"> - <button @click="search"><fa icon="search"/> {{ $t('@.search') }}</button> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n(), - props: ['q'], - data() { - return { - query: null - }; - }, - mounted() { - this.query = this.q; - }, - methods: { - search() { - const engine = this.$store.state.settings.webSearchEngine || - 'https://www.google.com/?#q={{query}}'; - const url = engine.replace('{{query}}', this.query) - window.open(url, '_blank'); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-google - display flex - margin 8px 0 - - > input - flex-shrink 1 - padding 10px - width 100% - height 40px - font-size 16px - color var(--googleSearchFg) - background var(--googleSearchBg) - border solid 1px var(--googleSearchBorder) - border-radius 4px 0 0 4px - - &:hover - border-color var(--googleSearchHoverBorder) - - > button - flex-shrink 0 - padding 0 16px - border solid 1px var(--googleSearchBorder) - border-left none - border-radius 0 4px 4px 0 - - &:hover - background-color var(--googleSearchHoverButton) - - &:active - box-shadow 0 2px 4px rgba(#000, 0.15) inset - -</style> diff --git a/src/client/app/common/views/components/image-viewer.vue b/src/client/app/common/views/components/image-viewer.vue deleted file mode 100644 index 63b5e28d00..0000000000 --- a/src/client/app/common/views/components/image-viewer.vue +++ /dev/null @@ -1,41 +0,0 @@ -<template> -<ui-modal ref="modal" v-hotkey.global="keymap"> - <img :src="image.url" :alt="image.name" :title="image.name" @click="close" /> -</ui-modal> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: ['image'], - computed: { - keymap(): any { - return { - 'esc': this.close, - }; - } - }, - methods: { - close() { - (this.$refs.modal as any).close(); - } - } -}); -</script> - -<style lang="stylus" scoped> -img - position fixed - z-index 2 - top 0 - right 0 - bottom 0 - left 0 - max-width 100% - 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 deleted file mode 100644 index 88cd4931d4..0000000000 --- a/src/client/app/common/views/components/index.ts +++ /dev/null @@ -1,103 +0,0 @@ -import Vue from 'vue'; - -import dummy from './dummy.vue'; -import userName from './user-name.vue'; -import followButton from './follow-button.vue'; -import error from './error.vue'; -import noteSkeleton from './note-skeleton.vue'; -import instance from './instance.vue'; -import cwButton from './cw-button.vue'; -import tagCloud from './tag-cloud.vue'; -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.vue'; -import poll from './poll.vue'; -import reactionIcon from './reaction-icon.vue'; -import reactionsViewer from './reactions-viewer.vue'; -import time from './time.vue'; -import mediaList from './media-list.vue'; -import uploader from './uploader.vue'; -import streamIndicator from './stream-indicator.vue'; -import ellipsis from './ellipsis.vue'; -import urlPreview from './url-preview.vue'; -import fileTypeIcon from './file-type-icon.vue'; -import emoji from './emoji.vue'; -import welcomeTimeline from './welcome-timeline.vue'; -import userList from './user-list.vue'; -import frac from './frac.vue'; -import uiInput from './ui/input.vue'; -import uiButton from './ui/button.vue'; -import uiHorizonGroup from './ui/horizon-group.vue'; -import uiCard from './ui/card.vue'; -import uiForm from './ui/form.vue'; -import uiTextarea from './ui/textarea.vue'; -import uiSwitch from './ui/switch.vue'; -import uiRadio from './ui/radio.vue'; -import uiSelect from './ui/select.vue'; -import uiInfo from './ui/info.vue'; -import uiMargin from './ui/margin.vue'; -import uiHr from './ui/hr.vue'; -import uiPagination from './ui/pagination.vue'; -import uiModal from './ui/modal.vue'; -import formButton from './ui/form/button.vue'; -import formRadio from './ui/form/radio.vue'; - -Vue.component('mfm', misskeyFlavoredMarkdown); -Vue.component('mk-dummy', dummy); -Vue.component('mk-user-name', userName); -Vue.component('mk-follow-button', followButton); -Vue.component('mk-error', error); -Vue.component('mk-note-skeleton', noteSkeleton); -Vue.component('mk-instance', instance); -Vue.component('mk-cw-button', cwButton); -Vue.component('mk-tag-cloud', tagCloud); -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); -Vue.component('mk-acct', acct); -Vue.component('mk-avatar', avatar); -Vue.component('mk-nav', nav); -Vue.component('mk-poll', poll); -Vue.component('mk-reaction-icon', reactionIcon); -Vue.component('mk-reactions-viewer', reactionsViewer); -Vue.component('mk-time', time); -Vue.component('mk-media-list', mediaList); -Vue.component('mk-uploader', uploader); -Vue.component('mk-stream-indicator', streamIndicator); -Vue.component('mk-ellipsis', ellipsis); -Vue.component('mk-url-preview', urlPreview); -Vue.component('mk-file-type-icon', fileTypeIcon); -Vue.component('mk-emoji', emoji); -Vue.component('mk-welcome-timeline', welcomeTimeline); -Vue.component('mk-user-list', userList); -Vue.component('mk-frac', frac); -Vue.component('ui-input', uiInput); -Vue.component('ui-button', uiButton); -Vue.component('ui-horizon-group', uiHorizonGroup); -Vue.component('ui-card', uiCard); -Vue.component('ui-form', uiForm); -Vue.component('ui-textarea', uiTextarea); -Vue.component('ui-switch', uiSwitch); -Vue.component('ui-radio', uiRadio); -Vue.component('ui-select', uiSelect); -Vue.component('ui-info', uiInfo); -Vue.component('ui-margin', uiMargin); -Vue.component('ui-hr', uiHr); -Vue.component('ui-pagination', uiPagination); -Vue.component('ui-modal', uiModal); -Vue.component('form-button', formButton); -Vue.component('form-radio', formRadio); diff --git a/src/client/app/common/views/components/instance.vue b/src/client/app/common/views/components/instance.vue deleted file mode 100644 index 497e4976f5..0000000000 --- a/src/client/app/common/views/components/instance.vue +++ /dev/null @@ -1,53 +0,0 @@ -<template> -<div class="nhasjydimbopojusarffqjyktglcuxjy" v-if="meta"> - <div class="banner" :style="{ backgroundImage: meta.bannerUrl ? `url(${meta.bannerUrl})` : null }"></div> - - <h1>{{ meta.name || 'Misskey' }}</h1> - <p v-html="meta.description || this.$t('@.about')"></p> - <router-link to="/">{{ $t('start') }}</router-link> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('common/views/components/instance.vue'), - data() { - return { - meta: null - } - }, - created() { - this.$root.getMeta().then(meta => { - this.meta = meta; - }); - } -}); -</script> - -<style lang="stylus" scoped> -.nhasjydimbopojusarffqjyktglcuxjy - color var(--text) - background var(--face) - text-align center - - > .banner - height 100px - background-position center - background-size cover - - > h1 - margin 16px - font-size 16px - - > p - margin 16px - font-size 14px - - > a - display block - padding-bottom 16px - -</style> diff --git a/src/client/app/common/views/components/integrations.integration.vue b/src/client/app/common/views/components/integrations.integration.vue deleted file mode 100644 index 51995843b1..0000000000 --- a/src/client/app/common/views/components/integrations.integration.vue +++ /dev/null @@ -1,48 +0,0 @@ -<template> -<a class="zxrjzpcj" :href="url" :class="service" rel="noopener" target="_blank"> - <fa :icon="icon" size="lg" fixed-width /><span>{{ text }}</span> -</a> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: ['url', 'text', 'icon', 'service'] -}); -</script> - -<style lang="stylus" scoped> -.zxrjzpcj - display inline-block - padding 6px 8px 6px 6px - margin-top 4px - margin-bottom 4px - border-radius 32px - white-space nowrap - - &:hover - text-decoration none - - &.twitter - color #fff - background #1da1f3 - - &:hover - background #0c87cf - - &.github - color #fff - background #171515 - - &:hover - background #000 - - &.discord - color #fff - background #7289da - - &:hover - background #4968ce - -</style> diff --git a/src/client/app/common/views/components/integrations.vue b/src/client/app/common/views/components/integrations.vue deleted file mode 100644 index 7a341a14fd..0000000000 --- a/src/client/app/common/views/components/integrations.vue +++ /dev/null @@ -1,26 +0,0 @@ -<template> -<div class="nbogcrmo" :v-if="user.twitter || user.github || user.discord"> - <x-integration v-if="user.twitter" service="twitter" :url="`https://twitter.com/${user.twitter.screenName}`" :text="user.twitter.screenName" :icon="['fab', 'twitter']"/> - <x-integration v-if="user.github" service="github" :url="`https://github.com/${user.github.login}`" :text="user.github.login" :icon="['fab', 'github']"/> - <x-integration v-if="user.discord" service="discord" :url="`https://discordapp.com/users/${user.discord.id}`" :text="`${user.discord.username}#${user.discord.discriminator}`" :icon="['fab', 'discord']"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import XIntegration from './integrations.integration.vue'; - -export default Vue.extend({ - components: { - XIntegration - }, - props: ['user'] -}); -</script> - -<style lang="stylus" scoped> -.nbogcrmo - > * - margin-right 10px - -</style> diff --git a/src/client/app/common/views/components/media-image.vue b/src/client/app/common/views/components/media-image.vue deleted file mode 100644 index b8b164aed0..0000000000 --- a/src/client/app/common/views/components/media-image.vue +++ /dev/null @@ -1,113 +0,0 @@ -<template> -<div class="qjewsnkgzzxlxtzncydssfbgjibiehcy" v-if="image.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false"> - <div> - <b><fa icon="exclamation-triangle"/> {{ $t('sensitive') }}</b> - <span>{{ $t('click-to-show') }}</span> - </div> -</div> -<a class="gqnyydlzavusgskkfvwvjiattxdzsqlf" v-else - :href="image.url" - :style="style" - :title="image.name" - @click.prevent="onClick" -> - <div v-if="image.type === 'image/gif'">GIF</div> -</a> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import ImageViewer from './image-viewer.vue'; -import { getStaticImageUrl } from '../../../common/scripts/get-static-image-url'; - -export default Vue.extend({ - i18n: i18n('common/views/components/media-image.vue'), - props: { - image: { - type: Object, - required: true - }, - raw: { - default: false - } - }, - data() { - return { - hide: true - }; - }, - computed: { - style(): any { - let url = `url(${ - this.$store.state.device.disableShowingAnimatedImages - ? getStaticImageUrl(this.image.thumbnailUrl) - : this.image.thumbnailUrl - })`; - - if (this.$store.state.device.loadRemoteMedia || this.$store.state.device.lightmode) { - url = null; - } else if (this.raw || this.$store.state.device.loadRawImages) { - url = `url(${this.image.url})`; - } - - return { - 'background-color': this.image.properties.avgColor || 'transparent', - 'background-image': url - }; - } - }, - methods: { - onClick() { - const viewer = this.$root.new(ImageViewer, { - image: this.image - }); - this.$once('hook:beforeDestroy', () => { - viewer.close(); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.gqnyydlzavusgskkfvwvjiattxdzsqlf - display block - cursor zoom-in - overflow hidden - width 100% - height 100% - background-position center - background-size contain - background-repeat no-repeat - - > div - background-color var(--text) - border-radius 6px - color var(--secondary) - display inline-block - font-size 14px - font-weight bold - left 12px - opacity .5 - padding 0 6px - text-align center - top 12px - pointer-events none - -.qjewsnkgzzxlxtzncydssfbgjibiehcy - display flex - justify-content center - align-items center - background #111 - color #fff - - > div - display table-cell - text-align center - font-size 12px - - > * - display block - -</style> diff --git a/src/client/app/common/views/components/media-list.vue b/src/client/app/common/views/components/media-list.vue deleted file mode 100644 index bfbc9366d3..0000000000 --- a/src/client/app/common/views/components/media-list.vue +++ /dev/null @@ -1,113 +0,0 @@ -<template> -<div class="mk-media-list"> - <template v-for="media in mediaList.filter(media => !previewable(media))"> - <x-banner :media="media" :key="media.id"/> - </template> - <div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container"> - <div :data-count="mediaList.filter(media => previewable(media)).length" ref="grid"> - <template v-for="media in mediaList"> - <mk-media-video :video="media" :key="media.id" v-if="media.type.startsWith('video')"/> - <x-image :image="media" :key="media.id" v-else-if="media.type.startsWith('image')" :raw="raw"/> - </template> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import XBanner from './media-banner.vue'; -import XImage from './media-image.vue'; - -export default Vue.extend({ - components: { - XBanner, - XImage - }, - props: { - mediaList: { - required: true - }, - raw: { - default: false - } - }, - mounted() { - //#region for Safari bug - if (this.$refs.grid) { - this.$refs.grid.style.height = this.$refs.grid.clientHeight ? `${this.$refs.grid.clientHeight}px` - : this.$store.state.device.inDeckMode ? '128px' : this.$root.isMobile ? '173px' : '287px'; - } - //#endregion - }, - methods: { - previewable(file) { - return (file.type.startsWith('video') || file.type.startsWith('image')) && file.thumbnailUrl; - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-media-list - > .gird-container - width 100% - margin-top 4px - - &:before - content '' - display block - padding-top 56.25% // 16:9 - - > div - position absolute - top 0 - right 0 - bottom 0 - left 0 - display grid - grid-gap 4px - - > * - overflow hidden - border-radius 4px - - &[data-count="1"] - grid-template-rows 1fr - - &[data-count="2"] - grid-template-columns 1fr 1fr - grid-template-rows 1fr - - &[data-count="3"] - grid-template-columns 1fr 0.5fr - grid-template-rows 1fr 1fr - - > *:nth-child(1) - grid-row 1 / 3 - - > *:nth-child(3) - grid-column 2 / 3 - grid-row 2 / 3 - - &[data-count="4"] - grid-template-columns 1fr 1fr - grid-template-rows 1fr 1fr - - > *:nth-child(1) - grid-column 1 / 2 - grid-row 1 / 2 - - > *:nth-child(2) - grid-column 2 / 3 - grid-row 1 / 2 - - > *:nth-child(3) - grid-column 1 / 2 - grid-row 2 / 3 - - > *:nth-child(4) - grid-column 2 / 3 - grid-row 2 / 3 - -</style> diff --git a/src/client/app/common/views/components/menu.vue b/src/client/app/common/views/components/menu.vue deleted file mode 100644 index 68fa0f5e62..0000000000 --- a/src/client/app/common/views/components/menu.vue +++ /dev/null @@ -1,196 +0,0 @@ -<template> -<div class="onchrpzrvnoruiaenfcqvccjfuupzzwv" :class="{ isMobile: $root.isMobile }"> - <div class="backdrop" ref="backdrop" @click="close"></div> - <div class="popover" :class="{ bubble }" ref="popover"> - <template v-for="item, i in items"> - <div v-if="item === null"></div> - <button v-if="item" @click="clicked(item.action)" :tabindex="i"> - <fa v-if="item.icon" :icon="item.icon"/>{{ item.text }} - </button> - </template> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import anime from 'animejs'; - -export default Vue.extend({ - props: { - source: { - required: true - }, - items: { - type: Array, - required: true - } - }, - data() { - return { - bubble: !this.$root.isMobile - }; - }, - mounted() { - this.$nextTick(() => { - const popover = this.$refs.popover as any; - - const rect = this.source.getBoundingClientRect(); - const width = popover.offsetWidth; - const height = popover.offsetHeight; - - let left; - let top; - - if (this.$root.isMobile) { - const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); - const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2); - left = (x - (width / 2)); - top = (y - (height / 2)); - } else { - const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); - const y = rect.top + window.pageYOffset + this.source.offsetHeight; - left = (x - (width / 2)); - top = y; - } - - if (left + width - window.pageXOffset > window.innerWidth) { - left = window.innerWidth - width + window.pageXOffset; - this.bubble = false; - } - - if (top + height - window.pageYOffset > window.innerHeight) { - top = window.innerHeight - height + window.pageYOffset; - this.bubble = false; - } - - if (top < 0) { - top = 0; - } - - popover.style.left = left + 'px'; - popover.style.top = top + 'px'; - - anime({ - targets: this.$refs.backdrop, - opacity: 1, - duration: 100, - easing: 'linear' - }); - - anime({ - targets: this.$refs.popover, - opacity: 1, - scale: [0.5, 1], - duration: 500 - }); - }); - }, - methods: { - clicked(fn) { - fn(); - this.close(); - }, - close() { - (this.$refs.backdrop as any).style.pointerEvents = 'none'; - anime({ - targets: this.$refs.backdrop, - opacity: 0, - duration: 200, - easing: 'linear' - }); - - (this.$refs.popover as any).style.pointerEvents = 'none'; - anime({ - targets: this.$refs.popover, - opacity: 0, - scale: 0.5, - duration: 200, - easing: 'easeInBack', - complete: () => { - this.$emit('closed'); - this.destroyDom(); - } - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.onchrpzrvnoruiaenfcqvccjfuupzzwv - $bg-color = var(--popupBg) - - position initial - - &.isMobile - > .popover - > button - font-size 15px - - > .backdrop - position fixed - top 0 - left 0 - z-index 10000 - width 100% - height 100% - background var(--modalBackdrop) - opacity 0 - - > .popover - position absolute - z-index 10001 - padding 8px 0 - background $bg-color - border-radius 4px - box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) - transform scale(0.5) - opacity 0 - - $balloon-size = 16px - - &.bubble - margin-top $balloon-size - transform-origin center -($balloon-size) - - &:before - &:after - content "" - display block - position absolute - pointer-events none - - &:before - top -($balloon-size * 2) - left s('calc(50% - %s)', $balloon-size) - border-top solid $balloon-size transparent - border-left solid $balloon-size transparent - border-right solid $balloon-size transparent - border-bottom solid $balloon-size $bg-color - - > button - display block - padding 8px 16px - width 100% - color var(--popupFg) - white-space nowrap - - &:hover - color var(--primaryForeground) - background var(--primary) - text-decoration none - - &:active - color var(--primaryForeground) - background var(--primaryDarken10) - - > [data-icon] - margin-right 4px - - > div - margin 8px 0 - height var(--lineWidth) - background var(--faceDivider) - -</style> diff --git a/src/client/app/common/views/components/messaging-room.message.vue b/src/client/app/common/views/components/messaging-room.message.vue deleted file mode 100644 index 1ab6359415..0000000000 --- a/src/client/app/common/views/components/messaging-room.message.vue +++ /dev/null @@ -1,279 +0,0 @@ -<template> -<div class="message" :data-is-me="isMe"> - <mk-avatar class="avatar" :user="message.user" target="_blank"/> - <div class="content"> - <div class="balloon" :data-no-text="message.text == null"> - <button class="delete-button" v-if="isMe" :title="$t('@.delete')" @click="del"> - <img src="/assets/desktop/remove.png" alt="Delete"/> - </button> - <div class="content" v-if="!message.isDeleted"> - <mfm class="text" v-if="message.text" ref="text" :text="message.text" :i="$store.state.i"/> - <div class="file" v-if="message.file"> - <a :href="message.file.url" rel="noopener" target="_blank" :title="message.file.name"> - <img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name" - :style="{ backgroundColor: message.file.properties.avgColor || 'transparent' }"/> - <p v-else>{{ message.file.name }}</p> - </a> - </div> - </div> - <div class="content" v-else> - <p class="is-deleted">{{ $t('deleted') }}</p> - </div> - </div> - <div></div> - <mk-url-preview v-for="url in urls" :url="url" :key="url"/> - <footer> - <template v-if="isGroup"> - <span class="read" v-if="message.reads.length > 0">{{ $t('is-read') }} {{ message.reads.length }}</span> - </template> - <template v-else> - <span class="read" v-if="isMe && message.isRead">{{ $t('is-read') }}</span> - </template> - <mk-time :time="message.createdAt"/> - <template v-if="message.is_edited"><fa icon="pencil-alt"/></template> - </footer> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { parse } from '../../../../../mfm/parse'; -import { unique } from '../../../../../prelude/array'; - -export default Vue.extend({ - i18n: i18n('common/views/components/messaging-room.message.vue'), - props: { - message: { - required: true - }, - isGroup: { - required: false - } - }, - computed: { - isMe(): boolean { - return this.message.userId == this.$store.state.i.id; - }, - urls(): string[] { - if (this.message.text) { - const ast = parse(this.message.text); - return unique(ast - .filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent)) - .map(t => t.node.props.url)); - } else { - return null; - } - } - }, - methods: { - del() { - this.$root.api('messaging/messages/delete', { - messageId: this.message.id - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.message - $me-balloon-color = var(--primary) - - padding 10px 12px 10px 12px - background-color transparent - - > .avatar - display block - position absolute - top 10px - width 54px - height 54px - border-radius 8px - transition all 0.1s ease - - > .content - - > .balloon - display flex - align-items center - padding 0 - max-width calc(100% - 16px) - min-height 38px - border-radius 16px - - &:before - content "" - pointer-events none - display block - position absolute - top 12px - - & + * - clear both - - &:hover - > .delete-button - display block - - > .delete-button - display none - position absolute - z-index 1 - top -4px - right -4px - margin 0 - padding 0 - cursor pointer - outline none - border none - border-radius 0 - box-shadow none - background transparent - - > img - vertical-align bottom - width 16px - height 16px - cursor pointer - - > .content - max-width 100% - - > .is-deleted - display block - margin 0 - padding 0 - overflow hidden - overflow-wrap break-word - font-size 1em - color rgba(#000, 0.5) - - > .text - display block - margin 0 - padding 8px 16px - overflow hidden - overflow-wrap break-word - word-break break-word - font-size 1em - color rgba(#000, 0.8) - - & + .file - > a - border-radius 0 0 16px 16px - - > .file - > a - display block - max-width 100% - border-radius 16px - overflow hidden - text-decoration none - - &:hover - text-decoration none - - > p - background #ccc - - > * - display block - margin 0 - width 100% - max-height 512px - object-fit contain - - > p - padding 30px - text-align center - color #555 - background #ddd - - > .mk-url-preview - margin 8px 0 - - > footer - display block - margin 2px 0 0 0 - font-size 10px - color var(--messagingRoomMessageInfo) - - > .read - margin 0 8px - - > [data-icon] - margin-left 4px - - &:not([data-is-me]) - > .avatar - left 12px - - > .content - padding-left 66px - - > .balloon - $color = var(--messagingRoomMessageBg) - float left - background $color - - &[data-no-text] - background transparent - - &:not([data-no-text]):before - left -14px - border-top solid 8px transparent - border-right solid 8px $color - border-bottom solid 8px transparent - border-left solid 8px transparent - - > .content - > .text - color var(--messagingRoomMessageFg) - - > footer - text-align left - - &[data-is-me] - > .avatar - right 12px - - > .content - padding-right 66px - - > .balloon - float right - background $me-balloon-color - - &[data-no-text] - background transparent - - &:not([data-no-text]):before - right -14px - left auto - border-top solid 8px transparent - border-right solid 8px transparent - border-bottom solid 8px transparent - border-left solid 8px $me-balloon-color - - > .content - - > p.is-deleted - color rgba(#fff, 0.5) - - > .text >>> - &, * - color #fff !important - - > footer - text-align right - - > .read - user-select none - - &[data-is-deleted] - > .balloon - opacity 0.5 - -</style> diff --git a/src/client/app/common/views/components/messaging.vue b/src/client/app/common/views/components/messaging.vue deleted file mode 100644 index 52f55e4333..0000000000 --- a/src/client/app/common/views/components/messaging.vue +++ /dev/null @@ -1,500 +0,0 @@ -<template> -<div class="mk-messaging" :data-compact="compact"> - <div class="search" v-if="!compact" :style="{ top: headerTop + 'px' }"> - <div class="form"> - <label for="search-input"><i><fa icon="search"/></i></label> - <input v-model="q" type="search" @input="search" @keydown="onSearchKeydown" :placeholder="$t('search-user')"/> - </div> - <div class="result"> - <ol class="users" v-if="result.length > 0" ref="searchResult"> - <li v-for="(user, i) in result" - @keydown.enter="navigate(user)" - @keydown="onSearchResultKeydown(i)" - @click="navigate(user)" - tabindex="-1" - > - <mk-avatar class="avatar" :user="user" :key="user.id"/> - <span class="name"><mk-user-name :user="user" :key="user.id"/></span> - <span class="username">@{{ user | acct }}</span> - </li> - </ol> - </div> - </div> - <div class="history" v-if="messages.length > 0"> - <a v-for="message in messages" - class="user" - :href="message.groupId ? `/i/messaging/group/${message.groupId}` : `/i/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`" - :data-is-me="isMe(message)" - :data-is-read="message.groupId ? message.reads.includes($store.state.i.id) : message.isRead" - @click.prevent="message.groupId ? navigateGroup(message.group) : navigate(isMe(message) ? message.recipient : message.user)" - :key="message.id" - > - <div> - <mk-avatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user"/> - <header v-if="message.groupId"> - <span class="name">{{ message.group.name }}</span> - <mk-time :time="message.createdAt"/> - </header> - <header v-else> - <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> - <div class="body"> - <p class="text"><span class="me" v-if="isMe(message)">{{ $t('you') }}:</span>{{ message.text }}</p> - </div> - </div> - </a> - </div> - <p class="no-history" v-if="!fetching && messages.length == 0">{{ $t('no-history') }}</p> - <p class="fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p> - <ui-margin> - <ui-button @click="startUser()"><fa :icon="faUser"/> {{ $t('start-with-user') }}</ui-button> - <ui-button @click="startGroup()"><fa :icon="faUsers"/> {{ $t('start-with-group') }}</ui-button> - </ui-margin> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faUser, faUsers } from '@fortawesome/free-solid-svg-icons'; -import i18n from '../../../i18n'; -import getAcct from '../../../../../misc/acct/render'; - -export default Vue.extend({ - i18n: i18n('common/views/components/messaging.vue'), - props: { - compact: { - type: Boolean, - default: false - }, - headerTop: { - type: Number, - default: 0 - } - }, - data() { - return { - fetching: true, - moreFetching: false, - messages: [], - q: null, - result: [], - connection: null, - faUser, faUsers - }; - }, - mounted() { - this.connection = this.$root.stream.useSharedConnection('messagingIndex'); - - this.connection.on('message', this.onMessage); - this.connection.on('read', this.onRead); - - this.$root.api('messaging/history', { group: false }).then(userMessages => { - this.$root.api('messaging/history', { group: true }).then(groupMessages => { - const messages = userMessages.concat(groupMessages); - messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - this.messages = messages; - this.fetching = false; - }); - }); - }, - beforeDestroy() { - this.connection.dispose(); - }, - methods: { - getAcct, - isMe(message) { - return message.userId == this.$store.state.i.id; - }, - onMessage(message) { - if (message.recipientId) { - this.messages = this.messages.filter(m => !( - (m.recipientId == message.recipientId && m.userId == message.userId) || - (m.recipientId == message.userId && m.userId == message.recipientId))); - - this.messages.unshift(message); - } else if (message.groupId) { - this.messages = this.messages.filter(m => m.groupId !== message.groupId); - this.messages.unshift(message); - } - }, - onRead(ids) { - for (const id of ids) { - const found = this.messages.find(m => m.id == id); - if (found) { - if (found.recipientId) { - found.isRead = true; - } else if (found.groupId) { - found.reads.push(this.$store.state.i.id); - } - } - } - }, - search() { - if (this.q == '') { - this.result = []; - return; - } - this.$root.api('users/search', { - query: this.q, - localOnly: false, - limit: 10, - detail: false - }).then(users => { - this.result = users.filter(user => user.id != this.$store.state.i.id); - }); - }, - navigate(user) { - this.$emit('navigate', user); - }, - navigateGroup(group) { - this.$emit('navigateGroup', group); - }, - onSearchKeydown(e) { - switch (e.which) { - case 9: // [TAB] - case 40: // [↓] - e.preventDefault(); - e.stopPropagation(); - (this.$refs.searchResult as any).childNodes[0].focus(); - break; - } - }, - onSearchResultKeydown(i, e) { - const list = this.$refs.searchResult as any; - - const cancel = () => { - e.preventDefault(); - e.stopPropagation(); - }; - - switch (true) { - case e.which == 27: // [ESC] - cancel(); - (this.$refs.search as any).focus(); - break; - - case e.which == 9 && e.shiftKey: // [TAB] + [Shift] - case e.which == 38: // [↑] - cancel(); - (list.childNodes[i].previousElementSibling || list.childNodes[this.result.length - 1]).focus(); - break; - - case e.which == 9: // [TAB] - case e.which == 40: // [↓] - cancel(); - (list.childNodes[i].nextElementSibling || list.childNodes[0]).focus(); - break; - } - }, - async startUser() { - const { result: user } = await this.$root.dialog({ - user: { - local: true - } - }); - if (user == null) return; - this.navigate(user); - }, - async startGroup() { - const groups1 = await this.$root.api('users/groups/owned'); - const groups2 = await this.$root.api('users/groups/joined'); - const { canceled, result: group } = await this.$root.dialog({ - type: null, - title: this.$t('select-group'), - select: { - items: groups1.concat(groups2).map(group => ({ - value: group, text: group.name - })) - }, - showCancelButton: true - }); - if (canceled) return; - this.navigateGroup(group); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-messaging - - &[data-compact] - font-size 0.8em - - > .history - > a - &:last-child - border-bottom none - - &:not([data-is-me]):not([data-is-read]) - > div - background-image none - border-left solid 4px #3aa2dc - - > div - padding 16px - - > header - > .mk-time - font-size 1em - - > .avatar - width 42px - height 42px - margin 0 12px 0 0 - - > .search - display block - position -webkit-sticky - position sticky - top 0 - left 0 - z-index 1 - width 100% - box-shadow 0 0 2px rgba(#000, 0.2) - - > .form - background rgba(0, 0, 0, 0.02) - - > label - display block - position absolute - top 0 - left 8px - z-index 1 - height 100% - width 38px - pointer-events none - - > i - display block - position absolute - top 0 - right 0 - bottom 0 - left 0 - width 1em - line-height 48px - margin auto - color #555 - - > input - margin 0 - padding 0 0 0 42px - width 100% - font-size 1em - line-height 48px - color var(--faceText) - outline none - background transparent - border none - border-radius 5px - box-shadow none - - > .result - display block - top 0 - left 0 - z-index 2 - width 100% - margin 0 - padding 0 - background #fff - - > .users - margin 0 - padding 0 - list-style none - - > li - display inline-block - z-index 1 - width 100% - padding 8px 32px - vertical-align top - white-space nowrap - overflow hidden - color rgba(#000, 0.8) - text-decoration none - transition none - cursor pointer - - &:hover - &:focus - color #fff - background var(--primary) - - .name - color #fff - - .username - color #fff - - &:active - color #fff - background var(--primaryDarken10) - - .name - color #fff - - .username - color #fff - - .avatar - vertical-align middle - min-width 32px - min-height 32px - max-width 32px - max-height 32px - margin 0 8px 0 0 - border-radius 6px - - .name - margin 0 8px 0 0 - /*font-weight bold*/ - font-weight normal - color rgba(#000, 0.8) - - .username - font-weight normal - color rgba(#000, 0.3) - - > .history - > a - display block - text-decoration none - background var(--face) - border-bottom solid 1px var(--faceDivider) - - * - pointer-events none - user-select none - - &:hover - box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05) - - .avatar - filter saturate(200%) - - &:active - box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1) - - &[data-is-read] - &[data-is-me] - opacity 0.8 - - &:not([data-is-me]):not([data-is-read]) - > div - background-image url("/assets/unread.svg") - background-repeat no-repeat - background-position 0 center - - &:after - content "" - display block - clear both - - > div - max-width 500px - margin 0 auto - padding 20px 30px - - &:after - content "" - display block - clear both - - > header - display flex - align-items center - margin-bottom 2px - white-space nowrap - overflow hidden - - > .name - margin 0 - padding 0 - overflow hidden - text-overflow ellipsis - font-size 1em - color var(--noteHeaderName) - font-weight bold - transition all 0.1s ease - - > .username - margin 0 8px - color var(--noteHeaderAcct) - - > .mk-time - margin 0 0 0 auto - color var(--noteHeaderInfo) - font-size 80% - - > .avatar - float left - width 54px - height 54px - margin 0 16px 0 0 - border-radius 8px - transition all 0.1s ease - - > .body - - > .text - display block - margin 0 0 0 0 - padding 0 - overflow hidden - overflow-wrap break-word - font-size 1.1em - color var(--faceText) - - .me - opacity 0.7 - - > .image - display block - max-width 100% - max-height 512px - - > .no-history - margin 0 - padding 2em 1em - text-align center - color #999 - font-weight 500 - - > .fetching - margin 0 - padding 16px - text-align center - color var(--text) - - > [data-icon] - margin-right 4px - - // TODO: element base media query - @media (max-width 400px) - > .search - > .result - > .users - > li - padding 8px 16px - - > .history - > a - &:not([data-is-me]):not([data-is-read]) - > div - background-image none - border-left solid 4px #3aa2dc - - > div - padding 16px - font-size 14px - - > .avatar - margin 0 12px 0 0 - -</style> diff --git a/src/client/app/common/views/components/misskey-flavored-markdown.vue b/src/client/app/common/views/components/misskey-flavored-markdown.vue deleted file mode 100644 index 40c444242c..0000000000 --- a/src/client/app/common/views/components/misskey-flavored-markdown.vue +++ /dev/null @@ -1,43 +0,0 @@ -<template> -<mfm-core v-bind="$attrs" class="havbbuyv" :class="{ nowrap: $attrs['nowrap'] }" v-once/> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import MfmCore from './mfm'; - -export default Vue.extend({ - components: { - MfmCore - } -}); -</script> - -<style lang="stylus" scoped> -.havbbuyv - white-space pre-wrap - - &.nowrap - white-space pre - word-wrap normal // https://codeday.me/jp/qa/20190424/690106.html - - >>> .title - display block - margin-bottom 4px - padding 4px - font-size 90% - text-align center - background var(--mfmTitleBg) - border-radius 4px - - >>> .quote - display block - margin 8px - padding 6px 0 6px 12px - color var(--mfmQuote) - border-left solid 3px var(--mfmQuoteLine) - - >>> pre code - font-size 80% - -</style> diff --git a/src/client/app/common/views/components/nav.vue b/src/client/app/common/views/components/nav.vue deleted file mode 100644 index 41b65604de..0000000000 --- a/src/client/app/common/views/components/nav.vue +++ /dev/null @@ -1,47 +0,0 @@ -<template> -<span class="mk-nav"> - <a :href="aboutUrl">{{ $t('about') }}</a> - <template v-if="ToSUrl !== null"> - <i>・</i> - <a :href="ToSUrl" target="_blank">{{ $t('tos') }}</a> - </template> - <i>・</i> - <a :href="repositoryUrl" rel="noopener" target="_blank">{{ $t('repository') }}</a> - <i>・</i> - <a :href="feedbackUrl" rel="noopener" target="_blank">{{ $t('feedback') }}</a> - <i>・</i> - <a href="/dev" target="_blank">{{ $t('develop') }}</a> -</span> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { lang } from '../../../config'; - -export default Vue.extend({ - i18n: i18n('common/views/components/nav.vue'), - data() { - return { - aboutUrl: `/docs/${lang}/about`, - repositoryUrl: 'https://github.com/syuilo/misskey', - feedbackUrl: 'https://github.com/syuilo/misskey/issues/new', - ToSUrl: null - } - }, - - mounted() { - this.$root.getMeta(true).then(meta => { - this.repositoryUrl = meta.repositoryUrl; - this.feedbackUrl = meta.feedbackUrl; - this.ToSUrl = meta.ToSUrl; - }) - } -}); -</script> - -<style lang="stylus" scoped> -.mk-nav - a - color inherit -</style> diff --git a/src/client/app/common/views/components/note-header.vue b/src/client/app/common/views/components/note-header.vue deleted file mode 100644 index a72863e1dd..0000000000 --- a/src/client/app/common/views/components/note-header.vue +++ /dev/null @@ -1,118 +0,0 @@ -<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"> - <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> - <span class="username"><mk-acct :user="note.user"/></span> - <div class="info"> - <span class="app" v-if="note.app && !mini && $store.state.settings.showVia">via <b>{{ note.app.name }}</b></span> - <span class="mobile" v-if="note.viaMobile"><fa icon="mobile-alt"/></span> - <router-link class="created-at" :to="note | notePage"> - <mk-time :time="note.createdAt"/> - </router-link> - <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"/> - </span> - <span class="localOnly" v-if="note.localOnly == true"><fa icon="heart"/></span> - </div> -</header> -</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> -.bvonvjxbwzaiskogyhbwgyxvcgserpmu - display flex - align-items baseline - white-space nowrap - - > .avatar - flex-shrink 0 - margin-right 8px - width 20px - height 20px - border-radius 100% - - > .name - display block - margin 0 .5em 0 0 - padding 0 - overflow hidden - color var(--noteHeaderName) - font-size 1em - font-weight bold - text-decoration none - text-overflow ellipsis - - &:hover - text-decoration underline - - > .is-admin - > .is-bot - > .is-cat - flex-shrink 0 - align-self center - margin 0 .5em 0 0 - padding 1px 6px - font-size 80% - color var(--noteHeaderBadgeFg) - background var(--noteHeaderBadgeBg) - border-radius 3px - - &.is-admin - background var(--noteHeaderAdminBg) - color var(--noteHeaderAdminFg) - - > .username - margin 0 .5em 0 0 - overflow hidden - text-overflow ellipsis - color var(--noteHeaderAcct) - flex-shrink 2147483647 - - > .info - margin-left auto - font-size 0.9em - - > * - color var(--noteHeaderInfo) - - > .mobile - margin-right 8px - - > .app - margin-right 8px - padding-right 8px - border-right solid 1px var(--faceDivider) - - > .visibility - margin-left 8px - - > .localOnly - margin-left 4px - -</style> diff --git a/src/client/app/common/views/components/note-skeleton.vue b/src/client/app/common/views/components/note-skeleton.vue deleted file mode 100644 index a2e09e3222..0000000000 --- a/src/client/app/common/views/components/note-skeleton.vue +++ /dev/null @@ -1,52 +0,0 @@ -<template> -<div> - <vue-content-loading v-if="width" :width="width" :height="100" :primary="primary" :secondary="secondary"> - <circle cx="30" cy="30" r="30" /> - <rect x="75" y="13" rx="4" ry="4" :width="150 + r1" height="15" /> - <rect x="75" y="39" rx="4" ry="4" :width="260 + r2" height="10" /> - <rect x="75" y="59" rx="4" ry="4" :width="230 + r3" height="10" /> - </vue-content-loading> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import VueContentLoading from 'vue-content-loading'; -import * as tinycolor from 'tinycolor2'; - -export default Vue.extend({ - components: { - VueContentLoading, - }, - - data() { - return { - width: 0, - r1: (Math.random() * 100) - 50, - r2: (Math.random() * 100) - 50, - r3: (Math.random() * 100) - 50 - }; - }, - - computed: { - text(): tinycolor.Instance { - const text = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')); - return text; - }, - - primary(): string { - return '#' + this.text.clone().toHex(); - }, - - secondary(): string { - return '#' + this.text.clone().darken(20).toHex(); - } - }, - - mounted() { - let width = this.$el.clientWidth; - if (width < 400) width = 400; - this.width = width; - } -}); -</script> diff --git a/src/client/app/common/views/components/page-preview.vue b/src/client/app/common/views/components/page-preview.vue deleted file mode 100644 index e3e73bd08f..0000000000 --- a/src/client/app/common/views/components/page-preview.vue +++ /dev/null @@ -1,138 +0,0 @@ -<template> -<router-link :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj" tabindex="-1"> - <div class="thumbnail" v-if="page.eyeCatchingImage" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div> - <article> - <header> - <h1 :title="page.title">{{ page.title }}</h1> - </header> - <p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p> - <footer> - <img class="icon" :src="page.user.avatarUrl"/> - <p>{{ page.user | userName }}</p> - </footer> - </article> -</router-link> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: { - page: { - type: Object, - required: true - }, - }, -}); -</script> - -<style lang="stylus" scoped> -.vhpxefrj - display block - overflow hidden - width 100% - border solid var(--lineWidth) var(--urlPreviewBorder) - border-radius 4px - overflow hidden - - &:hover - text-decoration none - border-color var(--urlPreviewBorderHover) - - > .thumbnail - position absolute - width 100px - height 100% - background-position center - background-size cover - display flex - justify-content center - align-items center - - > button - font-size 3.5em - opacity: 0.7 - - &:hover - font-size 4em - opacity 0.9 - - & + article - left 100px - width calc(100% - 100px) - - > article - padding 16px - - > header - margin-bottom 8px - - > h1 - margin 0 - font-size 1em - color var(--urlPreviewTitle) - - > p - margin 0 - color var(--urlPreviewText) - font-size 0.8em - - > footer - margin-top 8px - height 16px - - > img - display inline-block - width 16px - height 16px - margin-right 4px - vertical-align top - - > p - display inline-block - margin 0 - color var(--urlPreviewInfo) - font-size 0.8em - line-height 16px - vertical-align top - - @media (max-width 700px) - > .thumbnail - position relative - width 100% - height 100px - - & + article - left 0 - width 100% - - @media (max-width 550px) - font-size 12px - - > .thumbnail - height 80px - - > article - padding 12px - - @media (max-width 500px) - font-size 10px - - > .thumbnail - height 70px - - > article - padding 8px - - > header - margin-bottom 4px - - > footer - margin-top 4px - - > img - width 12px - height 12px - -</style> diff --git a/src/client/app/common/views/components/page/page.post.vue b/src/client/app/common/views/components/page/page.post.vue deleted file mode 100644 index cb695e21e9..0000000000 --- a/src/client/app/common/views/components/page/page.post.vue +++ /dev/null @@ -1,68 +0,0 @@ -<template> -<div class="ngbfujlo"> - <ui-textarea class="textarea" :value="text" readonly></ui-textarea> - <ui-button primary @click="post()" :disabled="posting || posted">{{ posted ? $t('posted-from-post-form') : $t('post-from-post-form') }}</ui-button> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; - -export default Vue.extend({ - i18n: i18n('pages'), - - props: { - value: { - required: true - }, - script: { - required: true - } - }, - - data() { - return { - text: this.script.interpolate(this.value.text), - posted: false, - posting: false, - }; - }, - - created() { - this.$watch('script.vars', () => { - this.text = this.script.interpolate(this.value.text); - }, { deep: true }); - }, - - methods: { - post() { - this.posting = true; - this.$root.api('notes/create', { - text: this.text, - }).then(() => { - this.posted = true; - this.$root.dialog({ - type: 'success', - splash: true - }); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.ngbfujlo - padding 0 32px 32px 32px - border solid 2px var(--pageBlockBorder) - border-radius 6px - - @media (max-width 600px) - padding 0 16px 16px 16px - - > .textarea - margin-top 16px - margin-bottom 16px - -</style> diff --git a/src/client/app/common/views/components/particle.vue b/src/client/app/common/views/components/particle.vue deleted file mode 100644 index 33c118f000..0000000000 --- a/src/client/app/common/views/components/particle.vue +++ /dev/null @@ -1,53 +0,0 @@ -<template> -<div class="vswabwbm" :style="{ top: `${y - 50}px`, left: `${x - 50}px` }" :class="{ active }"></div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: { - x: { - type: Number, - required: true - }, - y: { - type: Number, - required: true - } - }, - data() { - return { - active: false - } - }, - mounted() { - setTimeout(() => { - this.active = true; - }, 1); - - setTimeout(() => { - this.destroyDom(); - }, 1000); - } -}); -</script> - -<style lang="stylus" scoped> -.vswabwbm - pointer-events none - position fixed - z-index 1000000 - width 100px - height 100px - background url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABqUAAABkCAYAAAAPKjqIAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA25pVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMDE0IDc5LjE1Njc5NywgMjAxNC8wOC8yMC0wOTo1MzowMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDo1ZmMyNTFlNy02ZmI3LTg3NDMtYWFkNy1kZWQ2ZWY1NzIzYWUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NDIyMEQ0QjBFNTE2MTFFNkFGREZCRkYzMDQ2QkI0RDciIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NDIyMEQ0QUZFNTE2MTFFNkFGREZCRkYzMDQ2QkI0RDciIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTQgKFdpbmRvd3MpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6MUU1NkMyNUZFNTAzMTFFNkI1RjJFOTE0NTRGREQ2MDgiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6MUU1NkMyNjBFNTAzMTFFNkI1RjJFOTE0NTRGREQ2MDgiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz5nGnsGAABRHklEQVR42uydB3xUVfbH731TMiUNkkACCQRQOrYkoq4gYEVFZUXsfQUbCuta1nV33f/adQVFRVwrdnFlEV2wgQYVIYmutABSQnqAEFJmMsnMvPt/Z5LJDggkwMy8Mr/v5zO8kmHeuee8W94975zLhRAMAAAAAAAAAAAAAAAAAAAAgEgiQQUAAAAAAAAAAAAAAAAAAAAg0sApBQAAAAAAAAAAAAAAAAAAACIOnFIAAAAAAAAAAAAAAAAAAAAg4sApBQAAAAAAAAAAAAAAAAAAACIOnFIAAAAAAAAAAAAAAAAAAAAg4pj1KjjnHNYDAAAAAAAAAAAAAAAAAEBM8sela9NO2tnSeOGlOR61ZRFCdOl7vKtf1BpwSgEAAAAAAAAAAAAcOkXP1mYJbjuT+fg3uTMcW6ARdfnwnWVnZfOEycX9j/rX1SOTF0Mj6vL61vwJybVsakU6y78ta/QT0Ii6vFix/Bqv5L/VLKSNmd85bpxwSa4PWlHXHk0m9kac8K/sk58wRguOkFhvrxpM1o9MptbKzO+cg9S2R1d9TWaYDgAAAAAAAAAAAJGi4LnaVMlrf0kwNohzsaiyd/EDmFRU0R4zm4bZHWwNYzRxJFjBLNcpedOdP0Az6jB7wdahSQnyZ3XKfnrjrhvfXLnnXDim1OO5dfOT4j22f/ucjPVsZONf35pffF3/0YugGfXwC/H3FKs7S9k9sTaHfahsYQ8VIQchYybWwk0jS0/claecWg6tqIewyqNazV4zY7zPD2lxCcopXTgJ4ZQCAAAAAAAAAGAoCme6znP3trxI+/Yq74t5dzofhlbUQ2q1/8OeKF/Ufjgko3rIJmX7KjSjlkH4RW0OqfZDxsg2cEqpRG9L5WmMpXccm7auGaNs4JRSiYqaIdb0dHfHsSeOd4NW1EU2yVXKhpxSrFVybYJG1MUim15oMbGRgUipVakF0Ii6uOp3PpySlEZOwrV/GDd8p17khlMKAAAAAAAAAI6Ql1e0XttrpzyG9tNKXXfn3Z6yC1pRh4XvF9laZMsik7/tuLWH5e+rZjcuOXFaQhG0oxKc9drr0M8GQikqmkOIbXudkFgFtKIeWSVD5jcNrvuzj7EMOnb2dX0ArajHo+OG73y+LP+PvUtNdzOrf2XGGifsoTIZovziGnefK2Vf84pp/c7eCI2oy829R81TNvOgCW1w+7BL6pXNPXqTG04pAAAAAAAAADgC3ly5Z3xCLXul1SwFjhvSEwYom9HQjDp0K+1lZpl7n5P8pj7KBk4plRBm8a6yOT147DfJ86EV9ajqveGD9Moh/SSZj2FcFFX2Lp4DragHvcRQ8FztMRvyTHlNdTvW3XLKOaXQirq0ryOFtaQ0wqReV1XAHgAYC97Vxac0JzjnsB4AAAAAAIhJyAniEY7naL/F7v+i76Z1t2N9luiQ/2RVPG1H353RFDz3n489r7WapatDv9d9XW1y6HdAZCic6R7ATc2tOXeklIWe/+rD1nybl51K+34Tq7DvaDwe0WuRZ9Xsxhza7i8qrWimaywzseGyXyzNmxG/DtqKTl9BW6xPpA1erFh+DW3bowwA7AFgD83yUsniQbSdkj0ekWkagLIA0PbCS3M0v15UV31NcEoBAAAAAIAOKAWZ4OwvbQNF9n+/O9n6BrSirj1S69grtL+rG7uR7EFOkS2npjSEfs/P2PWwVeQpnO26oSXV8jLtx+3y/i53mvPVfe1EdG9iVaMnW7Ogscjbw2EXAXu4m9hVudPj3wl9eO+9Y/AVSjvmZMzz732dViAC9pjpOs+RIAKLz7sb+YTcGc5PoRX1mL1g69DBCXLA+behURo2bWL/9dCKuny4flUgqeikoSeaoA31mVOZHxg33dJr9LXQhvrACQKAMeiqrwnp+wAAAAAAQIDn19TlmJrYayFrn782e8HWgmzf7k2lRw25V0hSFpflsj6bix9HVE7kIefT7hBHBzk9Fr5f9P6aobZBjn3ibzwO/8nKBk6pCOPuaflbcJ0i2lc2AadUz/LVb5viho+haKkEN/t+zRDXDGgr8nAfP5+1N1hcSJOUTYdTqv1N0lehpShiZj33uw9U4aSKpB1Ng+uqgvvQiPo02TwXQQvaAc4obQFnFACxNmwEAAAAAABAIa7JOXzfc/b0zLztjow7/Nw0NXDCZGLbBw6jCJCbobHIsm5EY/cMOWWvc712Zsez9VvX7eg7YptZsH7B8za3aQU0Fnm8Ettm8rPewf3g+XYn7fXtHxAlBGdvKpuLQvaBilT02PBOr+rBGbRf3asY6Zf2w9dXrUyrn2BujEb6HUpXuWh+Ia2lxsbefhReJNkHevGj5Js9J2SflvxjtFKtXtd/9CJofv8svuyn/jubbJlJV7tX6SE9ldF5La90Im2vL+izANpQF4r89r5nu8ntlX6+5pMh+dCI+v34Llf8ZbDHkYP0fQAAAABQlTlLNvWRe/YOrMUi1VS8ecs5A7G4s0pQqp/49My1oeeaqsuHywMyf7X2x53HWDEYiwKhaxVZffKb515gCzg9KKqNSdKT1kZ739Y476u3He98GNqKPLR+0Y4B0gO037PE9RekhFOfgudqU2mL9aJ+zS8PLR3OnNbHA/2ro+m1AVPP+RBaUQdygKSWb/7a7LSdQMf+2urTh8ydsAyaUa9u1GxI/Yn2vbK3xCNLZ49/7/it0Iw6kAMkNcH3UfvhGnbL7hMQka8erxxb9lfOxV9pXwj+txt/zvobtKIe8/K2vefzmibTvjnTNQaOEJXbq6kF33HGT6T9lDOYHW3Vr0H6PgAAAABoHpqk+alX95WM8fTAiV59b39u3fzBtw+7pB7aiT603sSbK/ec1+A0PUbHiS7/fXTumdUt1R02ahtqVkNb0YGcUKtmNz5XMMbHbhvRrSh4vn1/HDQUXXJnOLYwREMdkMA6TrJ4zm9puT7Olbhqs2XXXyddMfbzSF4Tzqj9s2h+oVnsEB9aRWtgjQ7mTTj3l4eWjjj6gXFroZ3ok1L+y1Sz035CRy9qS36SmhRoRh08laYbg/sWyZLdI20PRYX8A5pRBxPzXxpyOML8LxNF+MFJqBIWq2+Iz9u27JnExRXKBk4pjeCvcaZAC+rCGaP1GU8UTKyacEkeHFJHAJxSAAAAAFCN7aPto/Z2dvD0pKYzT1F2FkM70YMiDYSw9jX5pET2PfMIxu5vG3RLnqKZrrE/+H3P+UyWh4Lfd7r4fdBa9DhxWkIRtHBgyBHS2s39JufsRHOyWMO97r9eeMo50JkK+J3N1/s9POC0a3E2nJjFrIsXzS+Mw1uk0adXVb8Mq7RhUOg5Ka11sLKBUwrEPJKFl+/VdnkZXrZREUem+ztWbw86ptb4LvaXsvegF9X6cmZ6X9kEInOYhWHMrzJpA/bcv3NLMvO2movT7qlZxC6BTtSk2zhpWv2bjjf7JDcUQxtHBtL3AQAAiAnojeGM8qF9ORN9lJ7PofSASYE/CF6v9ChuwXhpVeb67Zg4iywFM5uGcTMfyf1soKL7gYotBn13unlo6Hd+85VvvWKTjYqNNgkT2yR8YmXejPh10F746kLP6kHHSj4p/VDufUqzaEnKHnvcylYv1RvZLFfXpG/8OZbrzHPr5ieZk9MvtLWIum4Fzi+wBoI6fFGw8DF3c+q9wWNvi1wat8c+AO159Cl8t/BlipIKPSdVD+6JaCZ12vrBVeYKi+Tq0VE39rQgUkolaA2KHimti4Pp+zx7mq8+5oXRb0Mz6kCR+vGbN79U3+jIS01u+qRkrPNu9BnqQin8EjIbM1OdTe+NeWvkTmhE/TpC22ittwYAMA5d9TXBKQUAAGGGIg641zaSm1h/4WdDeFsUSILS3ia3t197lE2jYKJa+U6x8p2twuJZiQmb8FL0bG2WkONGcVk6W3CWq/SM9LawqZP/5lcMtJELVigk+TMutSzHeiFHRiCVU9WQ8YpOr1bqwGjl1K9SDpQPtvi3Z7bZpm8583er9Jj+e6KNBY8zNwTyR9QqdSefFrKvyChejMn/Q4fWwlGGiIOVtqckXE6+gJOR8WylZdvQntYsZviw8q3eNXKf761NPJOOPUn+gj75CWNwb0affxfkf8KbTeeFnvMetS1zUq+rKqCdKNeLd5adlWW1dkS6Ugq/464dcTI0ow5YU0pbBByFGxsG70k+thrjfgAAAAAYETilAIghaNJ34NLKk2Vb0gmyV2R6/VJPi8nf02QxBSJB/F5/vddvqrGY5BpKVSB56n/cNK7XCkychQ+amJU4v1rI7HR2+PnhC7nEvpKFeBNRIYcHvdHlsCVO5j5+udK/nRGm/uZLYRbvuj0NH+BNsa4TcAr6bX9kIpD6IdQRFXAuBaKgmFjNBa/ym+UGyWciZy2Tzf7kFadZPwlN6RfimOr4DcbZB9zkeTRWnYazt302SjLbT5Z9zSum9Tt7+cG+u2p2Yw5FRVVlFn8WqbeA2yIRh5xN0VOxkmrutfKvn/A1me4KPedPYNff3HvUPLQA0WXfSClhYrtMlXEZeOtdHbbMXTKpLjH1nIAtavvdh8l3AAAAAAAAYgM4pQAwOJQCIi2l5RrZK5/LmfiNciruEH+iRTD+nWSR/rOzNm4eQuQPnUAEyI7BVzAvu4WFf6HiQmZhcyp6bHgHzsPOoeg05rdN5X42g+0nEidM1AoTm8lMnrmYYDsw5IxiPvtDgokrWTAyjfP1QhLvMFn8uzOH6x+Xrk1LTx24I/ScSfjnjlzqn80kfhGX+RXKKCeY7s/PGX+bmZsfiCXn1PNl+fc0W/jjwWO7V9x7W9boJ37ViLRHRkXSGbUvQedULEROwSmlHSiNYs+K9JctTmmMzy27LU7Xb7GmFAAAAAAAAABEFzilADAoxVMXjfWZEh7gTIwLa6PB+FKzv/GhIXMnLIOWD05g0rV6yDXMyx5UDrMifLkyZmEPVqUXz8Mb3/u3Ra+qIbcIH39MuYsdUeqB3Nws7qvMKJ4Dm+xti/TKIfdyP7+/3RZ+pa/+WObiibzpzh8O5beeX1e/1Oe3jw0ed292n3v1yOSOdFAFs1wnSYLfo4xhLmABxxd3C5N4pLpX8eNGtwk5w385zd2873lbbXXy7cMuqe9oo8qHnK1mWtBgGtNoOsSibovvl+Ts6O5cFXquW+L2PkgZBwAAAAAAAAAgFoFTCgCDQc4oYUt+Uvb6ciJ5HcliLuKePXfDObV/AmmwvNKLLPyRUZ1RKFvkm2MlLVaXbeEzzQuJmol2R7ReNvuvgU3aI3KEeC9YL5Q+eoHM5D8fbhpKipbK7NZvipCkrCRPy8JQh1QogbSZTPq7MpaZGBRFufhlRo7QId2kDK3bse/52vXdejw6bvjOQKSa33ZUzgynJtrwopmusczk2WzUSDZKo2iOs//OJss1Xl/zK1Oyx29E6wwAAAAAAAAAIBaBUwqoQvlxl1hom/nf+V5oIzxQmr5EZ+szVpPv8mhet9VvfrfBZb0Taf3a+F8UCPsbC6Ykiz5+YWJ/jYVokM4oetY1TfjYTBVt0WETbmYzcu5wzo5VWxTOarqCydI/26Ojyhhnt+TOcH4aVRlmus5jgs1hgchF7maSfFPu9Ph3jKrz2VVf/9DCTSODx3HCv3JaxpiT2h21e7TmlCOnJa0Vtq8Dd81LRffbXM2/8Tjt321NYk8jVam67PrPM7lse+pdbs5LrAM2zUo/88EaaEU9di9+IIFJBbf56rOPd2dn/CX7xAfh8FTbJp+dfV9rNznX3tQwM2ncyu+gkUObeAj3c3zJqgcH2dh3D0ueYW/3GD1rASyh/rPSbxL/9Afa7372Z49BI+qzI3/6xG1DMweNTP0D7KEBPqx8q3eWtfrq6q/GzsKYVxus3PXUfQU1fecEs00A9Z9Fvj4upQpZJ7QDZWk5nPYKTikQUUqHTEr1ZeaeLjjLVSyRq9xF/VnbOi7O9q+4GC1mz9hW5W+FXLBCc3nhV32KP8Q6LIcARUfJ5oS3lXqaoVI9q5J8jVfGetQUpaGSvPZ3FTucoZH270vZ0nx5LK5rRJ1iZsWQuYKJazTVJzE+r7x38dRYecCgh6qdrM8jsszGDNzQq0+31RltqfoszVPUTBen1NOXglFTwsQeyLvT+bCR9P5SyeJBVtk5sFVybfJbnff7uDzILKSN/rqaO07+/NwTZItnjVbbhTb72EYEI7jIIcW81oeCf2/p3vJq7uW5v9NVe/T9kkDkslSR+rPeXxSo/uLBnp6KPh0PgLaklm/SJ95yul7kp8lQufeuYzML83YYJSpvxwdT31c2wShQ1mPyXKue7DF4Y8Pg9UcnbTZKv1i/dORvTL6W/ODxsvqX4/RS7/OfrIrPaCnOPvqBcWvD+btHOo9wpM/zjcuO+4l72TG03+o/P7n7+Ica9/c9emnypz/dK7RiL3rhUDq2T/PouzOaInmdQ7FPOOZWyAHiSdr8Ee1bd+SmH+jFBnK4m+K+OKbZP36zFl5+IHs0Xmiqi9T9oZbTNjD22vHntXb3zmGpv6Qf0B7UXo9OuS3wopMWnO1kD6O+FPvNjlmnOpgvf7svPetgk+7kcN+enZzW8M2pP6jdbtH9YeSXYckp1fenptcO1hZRm7UmJ/XYEUW7fj5QPwNbhG+uR5KEr7NyUl3a6UvdBueVNqA2i7ahL9DBKQXCDg3oW3rmXMcFu0K5a8Yclt2UcYbg7J24mqLXEU11cNbdmX+X8Pqf0kR9s5j+MOyZ0f+IRTtQKizhty9RLUXcgRvB9dzUfI5RU2IdaFLFaU5aEB7nIHcrXWVt+35KONajImehy1c/MdKTDFpg30idbvVpX9w4aPBZWpCt4BnXn7ifBZwdXGKPy2bPU8xvmxo4lvl7ek3t93xZ/j1WF3+0Y6BnaxoaTBVHKfJcvoYCrd97bXU4MY8cU/99Y80KUxPruIdsfv/Oo+84rqde7PFFwcLH3M2p9wZsYfd/elHe6PP1XKfpzcSmHQl7rf2Wfd0NZr3IX/hu4ct+S8v1tJ9a23jpgKnnfKj3drYzpxRNTpzs/O44n6WuTEtRbSTXoB1irVW0Dmrl1o3FqdJxRnBM7euU2nPP0bZ9n2XoodxRUvV/gYN0+3taiN6hdjelZOMmS5Iz3etqvjsc4/lwzx8c7nN99aozP4zfs3OisLDV9TOOPnF/z5YU3WblNYEXVFpFzz+pHcFDE+5mZq+k/Uaff9D4947fGs7fD4dtDtseXzzYkyV993xgLLb90cv2N6lI33Ga/70k6Ex028ZMUrOeLL7sp/6+HUlb6hr5+9cU9LtMS3Y4Elvs1SZ59gw9mI6pHrXGxV1M+/HV3j+qWUfmnV882lfu/Nqc6RpzzSdD8sP9+0dil2jNP5Jzd0cfT8C52+xIW5fX4+/D1bLHa3mlE03Mf2k464beIIfUlhy+grGkocpIYH3lsoty1RrTUP1Ii/eUh7vf0BvkSJRk9gjtd+bgjSSHG0FkNILO9kCfzsyjT+sx/dtDaW8lBkAnkDNqy1mP39XSI2czE+ylw3VIBW5M+r/Kb9Bv0W8G0/2BvVlz6zczteKQCthNkYVkijU7UMop4bN9pzmHVFsrP5RkC6zlEwNQp38EDqlaimRiFnajbJFz3f6GhNzfO5y5v3f2afs4nHSO/kbfCXxX+T+H8aBzBslIshpN/zTJSGvn0Oe5dfOTQh1SgQFIYnWiVmQNREdJ4kpG6S5ldi9vtW0gJ1XAUSXESorY0aMNQh1SBEVKBSZ+ZrlO0oNDiiAZSVaS2Wdt2Wu9MVeSaaue2qOgQyowUdFsOo+i2PRcx8mxEXoc3xw3P7j/y0NLh1NkG320WH9IpqBDiqhLTD3HEA1vuv294K45qeQvoX+iid0RrtUeciRShFvpi+88qhWxe1X1yyCHVKDdUrZDf6k/KvxDIHHInyOFIgjIqdGUnLbAb44bva8DhCauHCVVa1ibI3Eiq25+P5ASU2UoQoocUgF7ZEvjwqH3SNnzkNutKcmXky1cvovO2Z9DiupJ0CEVKL+yT85FNe3R7LMmROaxIHy2OdzfIud4+olfTKLPgd5yt8orrg86pAi7/+u/aOHR25Hp/k5rdgjH79Fb6wd1SCl1JOiQIprSLar2JdmnJf+Y2K/xzqSr3asi0V9o4Xc6Q7atu7Kjfrh3Dgs4e1Xi+oI+CyLtkIp2X37IjUNO6rFtDqnASGBo8tjlqvXr5KiNdYcUIcn1VwX341xNp6glBxxSbTTU2zqyejk2Jww7ZHtCheBgUPq4gDOKCXKQ9AnjT/eh36TfpmtA0yEd363fzORMnq41uUimWHJMBSbehKA0U1kaFjOLZNTrJHtXIYdIZuWQdw7VIUWRS8qNe35VVnF6zu8d1+ZOc75Ka9rsb/KeztHf6Dv0Xfo/9H8Dv3FoA+szSFaS2Uj6Lzm16dsWuyOfPjyp52pri2WvyQZKIaclmQPrSUn8EmXXz9pSywZJ4b64s4xiG3JKS5KnQk/ReSQrySxqhrzsj2crAw8UzLOptSH7dlorruhp9xu01XIdorQSwsT2SpPo3zCgWc/3Ek0mesuzj5M8ticDn+ziKXSeHFKebmmrA6kWlU8S3/otRV5oSfbqntv2hB7HNXJDPLDTRKLcLSOLPvu+ud66ZeBe40TZ5rlbzYmrUCiC2+sV/wnIya0bKYVfuH77SCalwjGhRXagCff9pbiSRdqvHNOS6T+qp32mlH0UIcXTWhYzV+u9R6L7SHOo1yBHFNniQJGCniSWvO+5FvPJPdS0B00o9hy863j6hGtyMVK20WtGnUO1x4Sl/Y+ZtGDY7CPVVST1FanfL9hzPr2EVxpyqlTtMSLZIhyTvZG0SSR/e3v/swuD+xQp1W4jQ3EkTqZoO6j2LBtVSBFSbUf16ymlIgPqjvvre/+NbFHSal8g/5i3yMhl1bLDtoP/5i6QJXY/fSrLjnvjUP870veB/UKTQUP/+SW9KfOHKF3yqfU3nfFHo+dI7Qwtpew7YN2LgVR+9BZ874rBy5XdXJ2IXFjRe8Moo76tUTTL9RhFvByKPoTEpuVNd4Zl0EhRHVxmsw/lfqC0cTnTnfcZQf8UHUXOqNBzx67NYMVH7/K1xnnNccK/Mq6l+dpgKjktEZrKr2NwJ7GTw3VvRJPXyr9+wtdkuit4bLGKi4Z+enwlOVP1eF+tmt2Y4/E0bazPrvBQ30/OqNC14ihikRzEWpX/9a35E5KrTK/Svlwn/vLb80+dE66HDy2NhSktXtzuuBtCz7V4+10VcPxqqZ94tjbLaiu5+pdezXUml/21cPWHh/OcFA277Jrz9rtN9pZL9jo5tHRYaC53LdikMmNbVTjG9pF4Xg23ncgpKNVV7Z1SOd1+qRZS+GlR/9GyTei6U5Tmz+s5f7Saa4Lo0TbhtAdFFFpsn+QHbUKRblpYxyhW6wdB0YPN8YkzaN/e1DAT9lDfJpTCb9vQzEGdrXUE20Rn3EVzoyP6fjJgzfbzt8T6fCXQRz3Rgr8Ea0qBw6Z84MXxrdl5i44kTd9h2ZSxr60lBRMyN/2rKRb1vvrW/Csl5n9LD7LKzHTVMS+Mftuotiic6XqBCXaLvhpFNid3hvNWA9riPMUWn3RRCW5mEdMo2ikissx23cC8fHaX15/i7HzFJp/q3Qb7c0oNXZX3+rkX2K6nVH63D7ukXsvyF810fxGMstO7s5BsYbE6+kneppUnLBh9lN7vL6rfVAaK9uSttl8tak1pNWNhjbYjfQCJ5Jh4f04ph7lishHWbIrkw2CkbbNl7pJJprjKjvR+tqSWb9In3nI6bKGujWgikdL20T6lXFR7/SK92CCSdiEnCJMKbqP9VulkTPBqpC0jJy5FgOh9glfv9QP9BexiFPtA99G3E3Su/Tqipo3glAKHhVoOqQ67xqhjihZZzeq+h8KC43QickvZ7uShRsxpWzjLPZHJ4iNdCi/x3+ZOdywwii3aJ6o3sL3Trx2IMsHF+LwZ8esiKtPMpmFc8MWsa2kda4XVMzjv9pRderfFnMr8N1wSD0SxJLm61fQobMnWS2ReIPKxckhR+9pwtcLEZgbWndJz3ZjlOqm6d3Gh3idz6M3D9Iohuczs2bwfp5Tf7W9INrpTSusOkI70fe1QqsVqd2auEe0SyWeiSNiGHFNJcu3Fbs5L4vuuf8RI0R+RtgeeK7VnA9hF+7aBPVA/YAvYReu2gf7VsRH0HlvPIOEsE5xSoIPy4y6xtPbI+Vwth1SHbckxtaPorP0tWGtU1t+5vFD2+nL0JLNkMRcNfWZUrpHsQOtkOEyJJaxrThAtUuv2N2QbZbLwECLWCoXVMz5azp92Zxk5pjq//w0SwVY00zW2KcG/lPYdjdLwSDv/wm8v+/a9ItwkcaXW0o91FXKyZewYOEyvafv2hdL4VfXYtC6zYsjc0PR9Ro3+1OPDB61dFucsvpT26+p7PQuHFJ5djGAP2EebdoBNtG0X2EQbdQP20J4tYBft2Ab6V88+0D2eQQ61XBJuARCkpUfOP9V2SAVuXkUGkiVW9E5p+/TmkCJIZpLdSLZwmBOfYPp1SBEp7WXQPTRR3SWHFOfro+mQIuhadE26dhcatFsCZdH7YInx55wNJhbfZHpcTw6pwEDHaxvxq5SLgp+qV1v0rhx8ulEcUgSVhcpUmbX+RmZhN5IzirZVmcV34OFDG7+fO8OxZcSUnEfoYzSHVDQXB1Z9IWLUD01eF3aATfSkE9hEWzqIdXtotfyoJ+rqAPpXT0fQvTb1pWW7wCkFAmw98zF6A1ZLC4pf2y6ToaHIHBOXn9Sr/CQ7lcEItqA3wZlgU/Tfs7EpgbLoHJPP1JV1GGq5qfkcNdLj0TXp2iRDmMqi3bpBKS3bUt+VubwND+nvZvJsVv71h57igjv1aAuKkhJMlERywNrZJzLNliiRZW6m9eAoOoq2Rl1IGA8fsWcL2EY/uoFttKcH2ASgbqCO6LHcsdx24aWG2LYPdK9NPWnVLnBKAVY6ZFKq4OwVzVVSRSaSzci6Tyn/ZarSOGTouCHNoDIYpFu4S/nHZICCmNrLolsoski5t87o9IucXZtzR0qZWnIGrs07d+ZTWXQdLSWL+wPlMLG5eoySCNjJQg5n7u6wCQs42XQHRRRFIlLtUBxOkXBOUZmobHj4wENhrOgGttGuTmAbgDqCOgJQF1BX9F9mtFWRf4bTwrVQT/RdJyS9V6hwfGIdb1buU8pGi2+NO9tlMyT0xjvzyXfpviBKGQJl0TG05owhoqQ6Gkg2JVAmnWLymjpN28UZn5c7w/mp2rKSDCRLOMqkyboxy3USa1s7q7a5teEZvd5TgQic3zucwupJY23RbbntZdMVnDG3Vgan4R4/RaJsAGj5gQzPIAD1A3VFrzrAZDtkAwAAoL/2Wmv9BSKlYpztgy7sx7SVtm9frm2X0XD0+8Z1sZ6jpEIatQwqi57LIMm2y5kxoqSCmNrLpDsoHaTSTU7q5Gu1srVZMw7ddlkOmsaPyqTHVJdcsGvadtgHRlhLJpDqUSnLXmXTCZSWszKzeLmWBqXhHNRS2YyQelRrDwCYqAKoH6g3QN+UDrrAAS0AoM/2OZb6E7zUANtA/+BQgVMqxvH1PfmvkFEdOJNvQFk00kn79DU5beQyOSwJFyjSH/Thm0vsZTXWkToQgfWlFJk6sYijrWz6YdH8QjMTbHJAes7mGaZuBMuilC1QRv1IPjic6yyF6yEhXL/TVjYxGA+CeCCMJV3ANgCALjQUNigBAP32mejrAZ5DgJZ0oSWbwCkVw5QPvJje2p+kA1EntctqGL6+amUaZ2KcUcpDZaEy6VH2omdrs1hbejKjkdteNn3dS0LqpE3ibtns0VxazzaZuPvIyqYt0iuGUL1IUT5ledOdPxilYrSXhdYiS2kvo07aWeOnt0MKPxCLD8Wx/rAOu6DMsMfBydr48W7YBGWFPQDuP9QNgDpiNHnglIphWvvmnce0uZbUvjjbZTUM3RNbzjLa/aTXMgk5bpRhOz4dlk3pHEcf7O+cs8+0FCUVJBAtpch2JGXTHJyd3rbhyyI9IIr2mo8dZWovo9ah1I+yxbNGq4PQcP0elVGPaS4BAACASFA28MLu0AIAAAAtPPPpXQ6gPeCUiuUGirPxkFUdOBOGi8zRa5m4LJ1t1Dqut7IVzGwaxtoicw7SFsgfared6lS2lPYy6mOAIPMxgXJJ8meRGpx2NkCNlHMqWKZgGbWO3ZI4XIvO2HBDZaSy4mEQD4MgRp5FNH5fot4A9ZFN0AEA+m+PjdyfIJIQAHC4wCkV24yFrKp1i8cYcDiiyzIJJoYadoCos7Jxzo7ttEzmls81q+8uyNaVMmpo8D6obct+VvvBIPyRPW1lCpZR83VDHNxZq4WHsnD9bjjLCkCk7/tYkQ8AoBoeqAAAAIDex5axPNZFquoDA6dUjFI6ZFKqsumjI5H7tMtsCCTOhhjtntJvmfhQ49Z0fZVNcN7v4MXh67UcLRKQTZHxiMqoEdpTqNGaZP7qzA0btTD4Ceegqb1Mfioj0sUBAAAAQJMjeQGnFAAAAACMCZxSMYpr9LUjILM6fH3VyjQhRIbR7ikqE5VNTzIXPFebqkjuMG5NF462MuqkQ/LzgQd9MGdso9bL0JmMnZVRK9jNCX3bdysnXJLrM1rNaC9T5T5lBQAAAADQDL03feyFFgAAAABgROCUilHitq7rAZnVodlnTTDqfaW3snGvPcnodV1XZeTsoPePYKJa60XoVMZOyqgdW/CgnDVh1c8RRjuFOcS8Zp+yarhqMHesjE9iqawAAADAwYdjHEoAAAAAgCGBUypGERJLhMzqcNTwOsNG5uitbLLZn2z0uq6zMiYc/MGcNWi9AF2QURdOqeC6PpzzPUatG8Gy6WENI5mz5lgZn8RSWQEAAAAAAAAAgFgETqlYRfBEyKwOvjJXmlFvK72VzeSTEo1e1Y1URpmzRsgYJTi3uhL9bE+WOW7h+0U2Q1cSpaxaEuePS9emPb+mLsfwetc4sxdsHUq2gCbU5cPKt3o/t25+EjShLovmF5phBwAAAAAAAEC4MEMFMQoXDUzoUGYjVLos505/pUEbFKVsepLXb5YbJK+xffNURqOURRLajzIiGYUBdP3DWH62nwd8IqMYG75t9oKtp0+b2H+9MbtDoZlosJdXtF7rcrLXfcpNVDJkePXza+rOv21EtyIMWqIHOaIye/T9kpvdx2QxJ3v+J9cDtx3vfBiaiS7kALGbR8xv9DWf4VRa/hcrll93c+9R86CZ6PP61vwJjUk7FnZX6sO7lfN+rK2zn377sEvqoZno882OWafWxTveUXZ7eHd7P7WtGHmpEdd91APkqDUPLp67J0Uca5Ybt9fU97gB9UI98p+sii/P3PxnX2NrliNeen3SFWM/h1bUtceO73dfT/uWyzz/vPDSHA+0oq49tnzgPTN9QO3P4987fis0oi704qF1gdQLtgAAkVIxC5dZA2RWh81ruxl2vQy9lU3ymfYYva7rrIwHjTISQvspPLsgo+YjqWhS3s9NU0Na33TeP+MOo9UNIUQgtaVW0sWR3skhFap3xs2vBuqxYPaYGZiqXNbeKQP+Tg6pDivYmx6iqCmMHKOLMyntT5K9+YzgcZw//nWKmoJmogtNvFuTmhZ2tJtxqSeQbaAZdWh3SGVRlbB0t/y2ecS6W6AVdSDdt0jSDfY6U46lPvm3A2TP09CKepBDinsa77ZYWi7ztjQvmbNkU5+wzkPoYG0vLcm4ZXP5Z009mp+mT91SeTbuUJXt8ba3SHjZv6o2pGxefNlP/aER9SAH4e5H034mW8zL2/YeNKIuVB+WXVH0C31QN1R69ocKYhNL2cqNkFkdnDnpO4x6X+mtbMLSbPg3GvVURsFE9UEfthg/WvMPhJ3I2FkZtUCvesevUpZxc+tAA1aPnm1GEZpwFGam9f7VBIpPjjum7b5hhl2L8Nd1RN2y7u9eT+7VvS9GjtHFI0k9fzUOLElNh2aiy/ah25xdsQ2IPOQgZG0OqQ76pu50QjPqkOCVjgs9burWkAytqIc5UR4Xepy2u2IwtKLq89CJIeM6vNijIl9ftTJNsUHH82n1lpRjoRX1KPlmzwlBe/i9puOhEXXp3b3xZItkyaaPq8V6HjQSfeCUitWBgixthszqMPrujCbOeZXh7imlTFQ2Pcmcd3vKLmVTa+CqXtteRl0gJFZx0L8LMUjzZehExs7KqAUoTZ9Zalkdei6uwf6GkSoGvaXmSvRnVeT+lxVe+uM9s7d9Nkr9+/9LpY/b22lpNjUvi0BbHak+IKy/R+nbVImMaTV/FXooS76G9a7yQq3pW69ydFlewfeyg89r2cHK0tZhBB9dKB0Zb9n1Y+g5u4d9CM1EH0rT593t/SjkVMvPTcMWxEo7oTXZHD1rX9urD2+K/zrW2m8tyWUWplc7unGvVNqjYvD3aDVUHdW+HtxL2OF4z8j1QOvyj3lr5E6zxf9BQAbGfuk3qOpb3J/q0e1a1/dBeyT0a3wOGlGX3Q1xnzuc9fOTEnfnpzqbELmmyjOfEFCCzju4w2XLWY9tVzZ9dCJu6YDP7zPMm8prbv36K87EOEMNPRlfOuKFMafrTe6ime4vlHbwDIO2bV/mzHCcqRd5C2e5JzJZfHTQMpk9fXLuSCnT5L30bG2W8NlKD/olif82d7pjgdZtQenKsuIyA46pigzvX8O1pk44xhzh6LMLZrlOWjFxzfLWOG/H2pq167v1eHTccFXXxXtz5Z7xu+32Vyl1HzkG5V92Xk5OwsKZrvNyZzg/DVt7HYGxX7jGUlTWwsk/pphamwMTGh6bdRXfvfPsaK3VQdEIpUcNuZdZ5MmMy7VMlu8+0nW9tDDWPhL7kE5s2RUP7epdf0ZyLf+5df3Q26KxPsSLFcuvsTSlXdWasKPK1Op6ZEr2+I16t0O47FP9xYM9y5xZFwX2K459JZLrCpGDmFL2UYQUOaSu6z96kZbbIrVsQzYxe7tlpZ57Z2GkZKF1KFqOXXsTRUhVbxr+xYWnnBP2NQe1ahMtPq8v/H5JTkv3mlN6dq/76bQe0yM60atFu2jNJmQPb0lcSvWxu1ZGasyA+tF1gqmwwrlujp7nLtW2EUVMkYMqluqFHupJpNGSbWJ13t1ozyDhLrOZgViG3sC+VkeyGgi+mhnMKdVWJh12EEz8omwM6ZRqL5ueBO70HhLcRk62VzUpfptsR1xGtSmc1XQF28bnMeY10XHvMN5FNOg5kkFZuAZNv/T8cWJr3N5joEHZu09SNovU1P3VI5MXK5sMmnjsXTVkPDP1PKngudodspDDmvbxSO0QycGs4KzW1NrcYQebp/XE1sSetMbZE9GwQfvk/sPtnw4ouo62eosIDge0dorfb77XXmdiLRLLcfYvp0mN+yJ93Zt7j1LaITYPw/VfU2bv8amfSScE6kh2Rb9I2qN9cveeSD0IG+FBfdd/nsltktgqFsdY1ZczX8w4Y8atkZCn3RmM9Vm6wNZlT99T7fU/lG4xPdB/7O8j0n+0OwWLoO3OIec2byy9Py05tXzy4GtnR9AeoItQNPqz2xvS80++NSJ6C6czKlag8X+kXvqJlENKD315uJ9VAADhA+n7Yhgu2GLIqg5mf8PHRrufdFsmzr8wbiXXV9lyZzi2KJuDRkFxH79cs+ruXLay9jJqG5k/pvxr6iiXzK8wWtXos+P47H3PtUquTVqRL6V60BvlXHxULrNXKvy21ZLPtIci8Yw+LqEy/nRRft2+5x1cTlVTrifm7nh8hTO+nj5Pzqm7UW8Pwkd6/W6NyXulUfTZGtLULM8Vr1XcfdM/d64+//WdM9vX2Yk5/JL9hOB+ckuFqi/WnPp67cVkkz8uXZvGYpQGu39cSIU7R01ZyA5HYgstTtwdjkzkkArd6n9Iz3UtT19r7Vmyh/2+pnrX0+Sggh3Ul2lmWc2cXj7vipdKFg+C/tWXe/SKF3L+2O/rJtoyoAnG/vD0I3q3h5HSiFPfoVd7GDUNLznSj1QGOKViGOvOIkqTVaoDUUvbZTUMm8b1WqFsWgxUpJb2MukOYW5ermz8Bqzi/vay6avDZvygUZFCiLGFM90DtCY3yUSyHUnZNIRjb6Wz7HBO/B7u4Cccg7nny/Lveb48f/uPF6397bFrM5jdJ0rjhH9lvJ9de6SpwcIFOWZ2MT455FSGkMRlzG87SouD47AOspUykh0oZV/o6SZvi2oRbJTKUvjtfwgey8L8kp4eRsJxXbc3bn7osc/mWqKWPa56Y9tYqzfxMa+wDUtttd3xuKvfhbH4QFiXYP7fejZS3D/Vkp0cUs1Cmr+ROR7/vKz367H6oC45GxbGMVFC+82S/KKa9vi8PrGKPnOWbNJLivaIQBFSoVu12qtLPl9WeMHyV29Uq63QSpu1vTXl8/SjbB9LNvZ0tNLx/qo/3/bZKBoHHumYVktt1pHIktLabWGNxf9URkGaKi/MUaTW4awdqrs1Mrso7+TEtM09Zf4EbfXY5hpxTUK/sP1Lr/YwIn3X93P9pnl0KTShHcIR2Yn0fTFM5n/ne7eclUOL7P1B46J+QLIarfL+OHX5R1aT73IjlKfVb/4oGutLRIK821N2Fc10LzPaulLK4GtZrlI2vcktuPiACXbNQb5iUr51l7K9VWOS38VCoosOWDY92MDEZnI/C75Z7FfOODLKh5yt7H8axvvzkNI8hONB5/Wt+RN2WfjjbaMfL/t5eBVL9LjOmpKpDWdUp3bZ11mogh0i/QAaLCOtIUUp+yhCihxS0/qdrTsHu6HGLKecU7Tw+yW5tHZK3O6e30845UrVUiSViMTuod7ZPsLTP5r3fCTq4OEwfsTVNyo2eeFY8w9N2Sc+qFobRvrf2N40CZkNidU6QjZY+H7R0JHdFyWln/5gjVpyJDP51Ob2/berU/LYYb58qKV6crh1pD1l3xNqyt6Uve0Kp8uSw5r73aIcvhLL/Ui7I+piNWUwx9l/V9q4+xo5t5lepFx+pPel2nXkSMdgH4+6IXBPqvXGnJfJC81y43a17wuN1ZH71bLHu5XzCi1MunBSr6sqYI02KLVlvkrXpufWcK3hqXZ7Fa7nxfYU61Ff/5migSRJ+CK5fmssg0ipGMdSVkgTdC4Ni+hql9Fw2Nmef6Is2kBw+TWj3V96LVNVZvFnyqb24IVjU7QULRWQRZGpk6/VtpdN8+Td6XxYSOxkJokrGWeBqBAu9orcCdsAtbNBale+0/XO5NcTprIlfqTW9J9zR0pZpszmhFYLZvLM5SbP6uC6Rlp4UAi3Q4rKRmUMrDuR2HOqZGLDWrhprdoOqWkT+6/fZq7viAySuG9KtHWtheuRY4rWAVF7zY677SWf2libPSzcs+7mXdKcWB3Dky3UdEgRffvWva48TS4WjJU4uHy3XupGJOShl7PSz1TPIUWM6FPzCGtI+Cyj3j77Xue2hVppP4xyXxwqZzdn/t3l9L5ck1A81Sg60bNNTK2uR/rGp1ybXZiyQu+6MML6OI2taVdyKekOI5dfT3YKh0PKSOkt1SalyLHYCHowQltF47twOKSMMNaNiBxaX5AOFSXybDnrsSeZdqOlnhrw+X13G1X36277ulKpgxk6rz9Vw54f00vPZaDJUIcpsYT6f4PcWrVuf0P26LszmvQofOFM1wtMsFs6ue++zJnhOFML8hbNdH/RaaQdZ3NyZzhv1Z8tyOEmNisFcAtrc988HUbfBQlESjn4XmvfxTW7R2s1CqdgZtMwifEeLl9DQbAuF810jc2Z4YzYS5RdGRNGasxEZavMLF5ecmrTty3c1OEstHvFvbdljX5CbXtQKqxSa2vzo+OG74yGnrU4tqV1agZl7z6J1l/TSrpLLdsAzx/6swlsoy2bwA7asg3soQ1bwA7a7DdgJ23aBPUF/QbsoI5dulpOREoBFldS8DemzbWlSttlMyx+Id2NMqgPTfhSyjLDdHRKWfTqkAp0kibPo6yTdb7ICVT0rGua2rKSDF1I/ehvL5PuyJ3h2EIOQErhJ/lsf9BzvaAUCN3q076wtlh89KF1pLScFi5vRvw6ckBFsy4HI9MO9okkJbm1J4c6pNqEYrdpwR63nDOwNBwOqWg8CETi98mp279fZY2XexZyYSqmdTn03B4YKdqA1uSgtVIoyhA2MY4csa4L2EFb+oE9tKEb2EEf+ohlOxllzTWj2iZaOonmtYC+gVMKsMxN/2pq7TfsOq3JRTKRbEbWfffvZ39AkUY67tiqqAyGMIbJM5eiQQww3HC3lUW/UPoyZRjzdmffEz7+WMEs10lqyUnXJhk6tYhSFiqTXu0hc/HngL5lPq3gudpUvZaDZD/20z6/OeVfI8wnLxgx6ubeo+bprm7McC5T856PZF2KZARYrDwYRvIBsNEu/yn02OrzPAYniPrX/WDDG9PMdbw8y70rP0uWShd+vyQHNlH/+iWrHhxU8P0rUxf98uIli+YXmmETfd0HsA3soaX+Nxq/j3qA+mI0HcAO6ukGuo+NsW64kPRsyHB9AGND5k5YpmhVQ2/B8z+0yWRsMv873yv5Gq/Uq/wkO5XBCLagtGTCJB7RezmoDHpOsdZRDs7+r3MnoXBwmX2ixvpSdE26NsnQSVvmbiuLjuvGdOcPHdFSrfZ/6La9Csiu3DNKWahMui2HYHaj9YXBMtE6D3HCv3Lvas6eN/qDyZGORaMxnpX80q9SDad3a4mH/tW7Fjk74rzxD3VUFcmTKCyOvxnBJnq+LjkGq319i/1MmpO2M+598+DiubCJuteiRcqrvpz5QtVXs7ZWL5jzVfUXD/Y0Uh+ix98nm5Djduuyp+8hJy76dPX7KHrRRK9Rt4i0hU1gB23oCLqPjbFuWJ8xcTuAIAM+v5cmG9/QgChvtMsSE5DzrdVvfldvcpPMRnMcVvcqflxpqdfruHdbHyiDAaC0cV10EqYwIVaumt0YtbfDA9dSrsm6sAYZlYHKond7CMZuZpStk4lraO0fvclPMpPsbWUIlEW3UESRHm1wMNsEo6RoEVm+e+fZtI6UUxbzUt3iAi2sJxWth4RDfVCI5mSVg8nvhx57bNZVR7ogdiw9HEbi92WZm8kRFXquuXdJOuyh7vWsCTv3Wj+yW6Pver1HS0XLJpG6xoge39zRwqWbWxjP9iS2jmVNPd8xWv+hN5ufmJL/tLdp9+xqr/+hyj3ONUZyFIb2z4ejQzVenn6pZPEgsbN0F9u+dilt6TgWxlFavvYFy1+98bLlz7TSZ/SKF3L0Wg+Mck1y1v5m5T8+po9e7RHJ+1aN+kd2GPvD04/QmreoH9q4HtmEXjo5lP8DpxTYi/U3nfE7pq5j6o12GWKKBpf1Tj2l8SNZSWaj2YEmRAUXN7JO1jPSKH6SncpgFHtUpm/4RxedhCmSV/qscJZ7YqRlomvQtVgXHFIke6AMBiCwtpTEnqJ9IdgbekrjR7KSzAGTKGUwgpNQZmJH/pNVuo9SoTJQWULP3T7sknpyRN3Sa/S1tA5YrI0HtBrpn7oi/v5Ws+0+ckaZLdanKotTJxhV91r/zSAXXprjiZPlV0PPmRrEG0ayhx6vY/Yk7mQGJVL3c6TbNLufZ4cei8SWfrCNevYgtsm+vZ73y5xZFxm53pQOviipfNAFeWWDLhimfFKVj4U+yrlE5W9WtbP4eKvX33CwY7RT0b0WTeo6WOOcjod8qfmvsIe61/lXY9m9dZydSx8926OzZ49o/L9wQI6oXabmlTWSuCff+dkrGOuqfx1yEPbyeVe81a3h20P5f3BKgb2gCe0Bn993nbL7lAqXf4qubaRJ9a4y5q2RO9vT+LXoQNwWkpVkNqItKK2XMDHdDTRIZj2nJNsfNOEmmDy5i2t9pTBZfFQ40/VCJCbr6Tfpt+karCsOKUrbp8hOZTCKPcozNjyobAqVT5bktb+rhze/SUaSlWQm2dvLoP92akb8Ooc58TS9l4PKQGXB6Esf40NyFk7LGHPS9Zlj7nl03HDDT7wf7gNcNB/SW9cPva3V5LuDnFMma+PkyYOvnW1EW+jpt+t7N842yc0/Bo9NTL7FaM824dJdtOqJybr9i70HzWIJ2i/12i3C4nCuDj3ut6epyMh9aN+NC+uzNi0qkASjZ5T7lM+jQogRyrkG5W+tasvXJ8VZGHqclpxajnZKvbZLksRefYZV8LVGsIcefzvW0OMyOOQoRN1Qv360cjE8MOayrz7mUNLAcqUzhOHAftl65mOXCs7I6+yM8KVcXLAb+39x3/uxrvPVt+ZfKTH/W1qWUWamq455YfTbRrdF0Uz3R0r7OFEPsirt4YKcGY7fGtUWhbNdNzAvO5Q3YMqYhT1YlV4870gngsipkVE95Brl+g+yNsdGF5+22Y2505yvGs4WtH5XMHUhZ3NyZzhv1ba8rheYYLcou7VKRRlphCip0HuzV/mQUcHUd/prY11jKzOLl8fiiygAgMMjHM+t0XiGDKQuydo5bKTrh/L0Mx+sgV3Uf47fkT99or+175nNJlGybtfop2Op79k+6EJat9HXd+NCzawDTOtIxTUk3VluizuRSXH/zDvlxrkMqMrzP/zj8R6ZtqN2lHs29yk77U9GrCNH0oeokZqMInLIIXVxQtbjlEkAdlDPJjTJ/m5T6Zu0b5Ltf8s/+dYitBrqQikua611FxrFHpHwzUSzjlCb1b118M2yVPLDx6NueKWr5YFTChyU0kEXZHj7nvKosntthC7xhmX793/ss/HjKmi7jXV35t8lvP6nNFnvLKY/DHtmdEys90UTCpmVQxYpbeQZGm8LvyzvVTzBSBE5+6NolusxIbN7D/G/lQkTmytxz7ycO1LKDul6z9ZmycJ2DfezqexQnFEskCLu8ZzpzvsMawtan0kweuvYpOj3gbw7nQ9rUc6CZ1x/Uuz3kLLrV4YMZ+rVeXPQMlIaRW9cT71FGxXMbBrGLC01eben7EKvDwCIxoM7nh0BAAAAfffn6MsB6oQ+6gmcUiCsFE9dNDZu27q/KHfLmLDYj7GvW/oN+78hcycsg3Z/TXvEFEWGxGlEpBaZmW6MhQipUChlm8OUSPdorkZFLHT7G8aOvjujKRbsERL1cjidxnrOxSJhYpuEn63ngu8UlubAG2fca08SXKRxExvK/WygEHyC0osOPczGTfPRQ2GxxaymK5jMA+2BFp1wezkxJXFl7vT4d4xbL9wDaKuXKDC9yQsAAAAAAAAAAESTQ/HXaM1HAqcUiAhlgy4a3tr3JIqamqx8+hzify9VPh9Yt//wRtbGf6+FNg8OOQJlc8LbSh3NULmuVdEaUrHqQCTHlNOctEBrEVMUIeXy1U+MFYdUkMOMmIqOTQweIbUv7WkVX1J2TZzxeT+MFZuY1Xe68Fk3marKHrnlnIGl4brWLw8tHS6ltQ6Wd1o3HP3AuLUHra+mpOcFE9coh35mYVOMmEZxX1bNbswxidYdhxoRGPX6+2xtlp9be5w4LQEpLwAAAAAAAAAAAIMBpxSIOGUDLzy6td/JY5hgA3l7JIlyN/UP2Iexre3HhcrBJuu2FV9nbVr4C7R2aCy+7Kf+fXs2fSB7fTlqXF+ymIu218RPHv/e8Vtj2Q6Uyq935eCnDztKJ+wNIJtT0WvD742esu9AtKdl+xuj9cO1gV+Y2F+1msYukhTOdJ3HBP+gfLA5bntmqD1Edf+Na7LCkY9+y9wlk3q66uZ39D0b48ftz0lOKeE4kz5oi3LjbmWEMzl3hvPTmKkXbeX3aDUCiSKkBJNteks1CAAAAAAAAAAAgK4BpxQABoEWs+//rftO4fXThHe00vm1cIvpT1tPdTyDRej/R+Es90Qmi38quykqiVDLJH5T7nTHgli3Rfu6Rm+wQ1zvKQKUKd3RtUZcr6irUJTOuty4ggYn26tjNnNX7m0juh1xRMyuOW+/a2uWLgsee+zye6m3XHl58Jicxr2qB9/F/fx+ZfjjIJvIFnliLEbjBNZCk22986Y7f9CSXAWzXCdJkqdC65FcAAAAAAAAAAAAOHy66muSoCoAtA05hYY9M/ofuxocWYJJs5RTLRG8XAtdg65F14RDam/IGSSsnsGUqiza16Zr0rXhkGqDnECKPk5Qwxb72OSEWHZIEeT8cTn8L+17fuTX1mPD8ftuzksOdLzmpaL7h9aYtrdFzgmHxyQ+cPsbhsZqejhy+lRmbPgvOdDphQa15SEZSBaSCQ4pAAAAAAAAAAAAEIiUAkBnfH3VyrSURM/9EheXhmu9KVo3Shb8/doG2yNj3hq5E1runMCb/4L/PdJrTdHaUTIXf9Za5IOWCERNMf5cW9q2qHRA6zkTt8e6MyqUOUs29ZEzMxb55Lhj6LhvOfPXdWt78WX4Ku+fm1sbnjnc9c92L34gQS4Z8pKt2Xyqx+77dpv/nGlc2C5IEr88muEr7hH83rb4gX8aMSXnkVi3g6/ZHJ/t272pV/mQUTITO9RKl0fpBCXGe6CeAAAAAAAAAAAAsQHS9wFgcOgN9KO+rBrlMyVeYLJIow513SlaL8rvlZeb/Q0fbz4jYzmiog4PSl1m8pruUFrSSe2pw8LRwrmVFu5Dv8X/bKxGfBxOfcioGDqByeJ+1r7GXQQoZBJ/pKr3+kWoL/sn4JzqnZbmE87C0PO/+dIXuKeFJH/GpZblhxo1Q2nphBw3isvS2cG6lmr9L0ttLe34zr5p/WKN535uftHPTVNp3yy1rJbKqyYMW5Ow22FJOlOYm5fn3Z6yK5zX++WhpcPjUquv9PLuReu7p/47WCcKnqtN5T77KLe3/ovDdUQCAAAAAAAAAABAf8ApBUCMQRFUPZ07hntNSb04E4FJea9f6klbi0muCTQMjBda/PWVNa4eaxERFV7yn6yKd1gSLmibNBdj2aGvdVTGGV9Gk/Zub+PHmMw9fMhRKPmkG5UbfjI78vW/ahlnH8hm+RU4CLvGyytar3U52euh545b5WHOBlNoJ75e6cU3KqOQTUxiFYIzF/PzusDfTKIbF8zJZNabCT5QGaUM2k8UXOFg9kk6Y3Jm8EQsO6XeXLln/G674z+h50zCP/f2Y+03037QUcSl5sJwpNEjh1SGo2ZNqO63e8+5R8j2XNnkL023LJpE53e2nv1CLKbtIyd5eUJir8zGhkotOLBp3TXaXnhpjgctFAAAAAAAAACASAGnFAAAqAhFdjC/7ShmYsNpcl1pahO54M5AA82FS2nCGmgynvnZWmbybMZ6K5EhkELMxMc1C3Gqzc/7K73H0ANHtHG38rf1HpPYauf8W9kvlqqV+kzPPL+mLmfvSClRffI3redzWTpHkvkYpS6ccuhRhdyt1JnvZUl8zWX+Xu4Mx5Zd/3km17YhfUGbY0oqr3KnjT/6gXFrY1LnP7n+5DNZHgo9R9FSmes2jSwbPHQWN/lOtlkqatzrrdNPKekZeFlBtnjWHG70VOmL7zza3c3vCz1XbD0rzfXDZt79hJ1Voef3+HOSY8nJTve/iHMvlWRzoiz5GniLY9xtI7p1OLQpmtA+sPrYeFvpj5N6XVURzmvTyykJJx5lC+1PZr249aJ4ufpftO/o5n/qistH3YtWSl1Ofb324j7C0//k5JZPp03svx4aUR9yJCMCGgAAAAAAgCMHTikAAABgPwSiRrz2JMHkQPQAZ5JHWJrrw53eLJahaKmWhJbfMy7XMlm+O3RSnqI2MnYMHCb5+SDO+DFM5hmKERKUPyW0f6WRCeUjiSrBxGrZJDZW9di0bn9RHuXHXWJpnnTLIPuHczZm/ne+N1b1/WtHYFukFJflMm5v6nBWCb9t2W3DksbRPkV3Os2Jecoo0KGMqNy0/lSzr3H7gRxI9H27OaEvrRPV1/bvKbZm6bL//VUqj//9pVlb5i6Z5HL53gv9f06n+bIBU8/5MGZssbbxZ252H9Ohc59j9W3DE46l/dnbPhsVb+rxTfBv3ctr8i485ZywRGCuuzP/Ln/fhsdp386TP9uQabuQJtlffuF7f+j3mjPSR8SKI4TamqpT3ZP9ski3+F0Lp2SP37jv33f/pvH/bFLpuHrR79mbe4+aF2mZrnit4u4Ef9NjweNzE5qciGD7Hx9WvtU7zbyr32k9pn8bjeuRM+pB94CFTGbjM7jvmU+uS5sBK6jL7AVbh35Wn3BTMm/6+K1r+2FNQgAAAAAAnQGnFAAAAABAjNCWNlE8poyQ0s2m5mWCfT6Rs7MWcJNnbOj3bh3c84ADqEAaUnNSz4DDlvOE9hFlIzlu3b76mqDDavfiBxLkkiEvtTmmpPIaZ9IMcjwVT1001juIfxH6m3a347hYiWCjCe6yEVm/co5mrSmzkINoTmX+GzZfwtUdf5Dqn7o+c8w94bhu37LqvRwb5Ayktb5qdra2hJ6PFacU6WTHMfH/kezNZwTP7esEfL4s/55ujv85iJoa+ZB9HVdHAkXFtZSYT4jL9v14yzkDAwvgnf/6zpm9fbvvCH5neIolZpyEL5UsHtQipB59VqUW7M8R982OWafWxTu+VHbjvLu9H12SedvFkZbpqje2jS2Wk78KHp/Vt6Lno+OGI701a3PaWoeuf572t0i2398+7JL6aFz3hNfqtiqdVLZgrOTH67v1hyX2blNKra3N0bxHqY6YW1K/tLH6JS9OyTwPVlCfJ+fU3Timxboub7rzB2gDAACAFumqr0mCqgAAAAAA9M3vTra+cecxcRnZxWvsFA0VmEBsNX+11+DQbzvoW+fkdKLUiJS2kiY7Ah9ln86FRlB1H/9QI63f1TRiY/qyrAH9gpFQQ+ZOWJbsaHgy+D3aj6WUiuR42lfHdBxMC2aT29Z3DOIW0q5wXfdA57v3kW8PHlvNze/HigOkKm/ngFCHFFGZ7bgj9NjB5dTQ456VPD5c1//wnWVnWbbu2kapE2lLx4E6wZs+Dn6Hy84lsWKP18q/fiI+QRSnJPq/Kf+NayNFRO37HZvkO1XZxNG+pbslLxpyZWW51pLzI1BXle0ptZ66WLAH6f+DDW9MW/j9kpwDThKcUDChRZJuoE8Pk/uaaMnGJVaMHv3XUARZctXmkmO2l9Y8t25+UrSuK8vWwDrFfh6XBSv8D3Lavj614J+v5ZVOjOZ1yTEpC/NLKyy+F2GFvaEUxmpct3Cm64Vvn2l6HxbQBrSEQsEs10nQBAD6AE4pAAAAAACDEBqBkLlpwz/klsS5lEIujtd9IZXVXBfOa6Wf+WDNvg6RPjdf8UdaR4o+tB9zA2tFx+SIovWkaBuqc1f9zoflZvuXPq9lh8fc+KZoqJkbruvy+Iy/BPcpfd/a5LRPaP+35586Z8vgxHRv/9R+10w5/YpYrhv7OgUTpPJneMuuH2m/viX+zXClUiTcTfJ1+zumdGTd+3vTXRbrGed1q74wFvROk7dxdvcfgsfd7E1ZjXLmnft+7+emYQuUTWA9tAx3wwvhloOi1Ka+vNF/0z93rqYJfjpHESdn960YaefyJbSNhXWlKCLNnOzeYu3T8qw4rrRw0S8vXrK/71VZ+q4O7vfsXvdTtOQ7K6viOrLHTRk7x8RC/SAH4eI1b74yv/z5fx3IFmpyqaN6dqul4b5ma9OdsWAPirKllwjoQ/sH+p51gdRL6XmvYyfU/CGa8lHUrcR9U2RT7YMY8f4PckhtXZtR8+HEddOifnEuvrVJ/DNYYW9eObbsrwerQ5HCz609JInlwQK/btvUujald4cFtDUu15I8SN8HAAAAAACAzvnloaXDabthUOKGWJhc74zQdInkCBSmstPCmZ7vYLzz7vLH3XWmjslKRzf/U1dcPureWLQDRXV0T3fuFYHU0uzYb+pKmjTZPnSbM9yp4igFmdPb+mXHc6TsjNlUZOT8sHS3/DZ43LzHVnR5r2tyD2S79G4t8ZN6XVURbjlofbWNzPE4Rag5uHz3t9el/CsW7fFu5bxCe52pI2Jth7P74AO1U+RMNdt9TcF0oOHm1NdrAykz73VuWxirfci8l756x2JpCayZ6fXGvXewlznmnV88Oi3eUz7+veO3YgQSGcjJ1NSj+WnBxKr0evPVB9M1Ra31G1T17Zi3RiIFa4RYfNlP/SuLU6+WrGL19QV9FnRmu7grmudgPBp5m3SlDVp9a/6Vx7ww+m1oTBuQY8joa8piTSkAAAAAAABAzPL61vwJnjjebfvGboujuQ4Lrc9WkrT+JcG8p3Bm+T67fuiU0BSYsQY5CJPimjrWUwv3+l2dAafU/zgUp1SkoMmY/3P3dwePY3n9qH+ve3uvyRiTtXHyhKNvnh9tOSiSsEqY26KhJLa46NpuMVc/yCne1LSjNfRc713HJ6rRdpMDcvWuhPe8wjZsmN835e5bur0Sa/agdqJuqWj83xnx+nVz825SSx5ay0tI3oEJffc8HynHsNbrx66He6xTGqyj6dic6RpzzSdD8tWSh8ZZdnNCX0ozHqtjq3l5297zeU2TzRb/B9cU9LsMo351IUds/x67T66pj38ALyt03Sllxq0DAAAAAAAAMBrX9R+9SI3rtk9iXgELtJH5nePG2hz2oaN1W7aUaPpoSnb4I28OxqWO2uWf1qUvEZLrHDpuivM+Fau24FLSHYy5KbURrRFU1su8e3q0ZZAkgTfn2/Em7fnIUp/c4SRsccZ/r4YclcJ8YccrvzIbH4u2oIiON15aUmq1yH3ouNUrlar1MsFn9Qk3pQrbMNpfZzK/pGxizimlpXZi1otbL2qRFTv4zcy7Je5s5dRxsWaPpJLetp3Me3Tw2F/jTFFLFnJIOUyJ65lgvYpmuZ7Kme68L9bs0Zay0jSZ9skxpejkd2q+/FT0rGuaKWnrFf7d/e/PmeFcFov2MDP/025XEuuZFAj2V/UZoOC52tTuloIxFQ3HLtH6S3FYUwoAAAAAAAAAQESgyV5yEE4efO3sSKSC68r1KTKK1vM6N6HJSWt7xaotSP+mT0/o79vjyOSLcgae1mP6t2rYg9aNogipYPq+WLVHTX2PG1pNvjuc7vjHeX1Krhr1g+jFfQs7DiS2OFbt0ZA28FxK20cf2ldLjkTRWhnct3BPTEaCUDshVThPp9R99Ml2yferJYtXdEthMQ5NbAvB/8YZ+4UicwZMtnyhliw2W/wgarZoX8js9Fi0B6WpJDvQPm3VdjzYHeIZq7ffSFP3rY/Eoj0aLzTVeWVvCe1v3dF9hdryWBIqF+1KSXjfkVkxS+u6Q/o+AAAAAAAAAAAAgBiEUnM97up3Ie3fbS/51OhrXWgdSl33vjt9WgO39jovvW5mLKaL01r9KN7d52GbL+7sk2TpxhOnJRRBK+pSONP1AhMsj3H2YO4M56fQiPr2iLNuvblF7v+73GnOV2O1nUhY6O+mhXXtCt8tfNlvabnesdv25xFTclRxFHbZ10Rf1OMHAAAAAAAAAAAAAAAAAAAAtKXwU/P6XfXtcDh4AAAAAAAAAAAAAAAAAAAAQKTBmlIAAAAAAAAAAAAAAAAAAAAg4sApBQAAAAAAAAAAAAAAAAAAACIOnFIAAAAAAAAAAAAAAAAAAAAg4sApBQAAAAAAAAAAAAAAAAAAACIOnFIAAAAAAAAAAAAAAAAAAAAg4sApBQAAAAAAAAAAAAAAAAAAACIOnFIAAAAAAAAAAAAAAAAAAAAg4sApBQAAAAAAAAAAAAAAAAAAACIOnFIAAAAAAAAAAAAAAAAAAAAg4sApBQAAAAAAAAAAAAAAAAAAACIOnFIAAAAAAAAAAAAAAAAAAAAg4sApBQAAAAAAAAAAAAAAAAAAACLO/wswAA1Niv+YaMCdAAAAAElFTkSuQmCC") no-repeat; - background-size initial - background-position 0 0 - transition background-position 1s steps(25) - transition-duration 0s - - &.active - transition-duration 1s - background-position -2500px 0 - -</style> diff --git a/src/client/app/common/views/components/poll-editor.vue b/src/client/app/common/views/components/poll-editor.vue deleted file mode 100644 index 51c73003d1..0000000000 --- a/src/client/app/common/views/components/poll-editor.vue +++ /dev/null @@ -1,235 +0,0 @@ -<template> -<div class="zmdxowus"> - <p class="caution" v-if="choices.length < 2"> - <fa icon="exclamation-triangle"/>{{ $t('no-only-one-choice') }} - </p> - <ul ref="choices"> - <li v-for="(choice, i) in choices"> - <input :value="choice" @input="onInput(i, $event)" :placeholder="$t('choice-n').replace('{}', i + 1)"> - <button @click="remove(i)" :title="$t('remove')"> - <fa icon="times"/> - </button> - </li> - </ul> - <button class="add" v-if="choices.length < 10" @click="add">{{ $t('add') }}</button> - <button class="add" v-else disabled>{{ $t('no-more') }}</button> - <button class="destroy" @click="destroy" :title="$t('destroy')"> - <fa icon="times"/> - </button> - <section> - <ui-switch v-model="multiple">{{ $t('multiple') }}</ui-switch> - <div> - <ui-select v-model="expiration"> - <template #label>{{ $t('expiration') }}</template> - <option value="infinite">{{ $t('infinite') }}</option> - <option value="at">{{ $t('at') }}</option> - <option value="after">{{ $t('after') }}</option> - </ui-select> - <section v-if="expiration === 'at'"> - <ui-input v-model="atDate" type="date"> - <template #title>{{ $t('deadline-date') }}</template> - </ui-input> - <ui-input v-model="atTime" type="time"> - <template #title>{{ $t('deadline-time') }}</template> - </ui-input> - </section> - <section v-if="expiration === 'after'"> - <ui-input v-model="after" type="number"> - <template #title>{{ $t('interval') }}</template> - </ui-input> - <ui-select v-model="unit"> - <template #title>{{ $t('unit') }}</template> - <option value="second">{{ $t('second') }}</option> - <option value="minute">{{ $t('minute') }}</option> - <option value="hour">{{ $t('hour') }}</option> - <option value="day">{{ $t('day') }}</option> - </ui-select> - </section> - </div> - </section> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { erase } from '../../../../../prelude/array'; -import { addTimespan } from '../../../../../prelude/time'; -import { formatDateTimeString } from '../../../../../misc/format-time-string'; - -export default Vue.extend({ - i18n: i18n('common/views/components/poll-editor.vue'), - data() { - return { - choices: ['', ''], - multiple: false, - expiration: 'infinite', - atDate: formatDateTimeString(addTimespan(new Date(), 1, 'days'), 'yyyy-MM-dd'), - atTime: '00:00', - after: 0, - unit: 'second' - }; - }, - watch: { - choices() { - this.$emit('updated'); - } - }, - methods: { - onInput(i, e) { - Vue.set(this.choices, i, e.target.value); - }, - - add() { - this.choices.push(''); - this.$nextTick(() => { - (this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus(); - }); - }, - - remove(i) { - this.choices = this.choices.filter((_, _i) => _i != i); - }, - - destroy() { - this.$emit('destroyed'); - }, - - get() { - const at = () => { - return new Date(`${this.atDate} ${this.atTime}`).getTime(); - }; - - const after = () => { - let base = parseInt(this.after); - switch (this.unit) { - case 'day': base *= 24; - case 'hour': base *= 60; - case 'minute': base *= 60; - case 'second': return base *= 1000; - default: return null; - } - }; - - return { - choices: erase('', this.choices), - multiple: this.multiple, - ...( - this.expiration === 'at' ? { expiresAt: at() } : - this.expiration === 'after' ? { expiredAfter: after() } : {}) - }; - }, - - set(data) { - if (data.choices.length == 0) return; - this.choices = data.choices; - if (data.choices.length == 1) this.choices = this.choices.concat(''); - this.multiple = data.multiple; - if (data.expiresAt) { - this.expiration = 'at'; - this.atDate = this.atTime = data.expiresAt; - } else if (typeof data.expiredAfter === 'number') { - this.expiration = 'after'; - this.after = data.expiredAfter; - } else { - this.expiration = 'infinite'; - } - } - } -}); -</script> - -<style lang="stylus" scoped> -.zmdxowus - padding 8px - - > .caution - margin 0 0 8px 0 - font-size 0.8em - color #f00 - - > [data-icon] - margin-right 4px - - > ul - display block - margin 0 - padding 0 - list-style none - - > li - display block - margin 8px 0 - padding 0 - width 100% - - &:first-child - margin-top 0 - - &:last-child - margin-bottom 0 - - > input - padding 6px 8px - width 300px - font-size 14px - color var(--inputText) - background var(--pollEditorInputBg) - border solid 1px var(--primaryAlpha01) - border-radius 4px - - &:hover - border-color var(--primaryAlpha02) - - &:focus - border-color var(--primaryAlpha05) - - > button - padding 4px 8px - color var(--primaryAlpha04) - - &:hover - color var(--primaryAlpha06) - - &:active - color var(--primaryDarken30) - - > .add - margin 8px 0 0 0 - vertical-align top - color var(--primary) - z-index 1 - - > .destroy - position absolute - top 0 - right 0 - padding 4px 8px - color var(--primaryAlpha04) - - &:hover - color var(--primaryAlpha06) - - &:active - color var(--primaryDarken30) - - > section - margin 16px 0 -16px 0 - - > div - margin 0 8px - - &:last-child - flex 1 0 auto - - > section - align-items center - display flex - margin -32px 0 0 - - > :first-child - margin-right 16px - - > .ui-input - flex 1 0 auto -</style> diff --git a/src/client/app/common/views/components/poll.vue b/src/client/app/common/views/components/poll.vue deleted file mode 100644 index bd5eeaf832..0000000000 --- a/src/client/app/common/views/components/poll.vue +++ /dev/null @@ -1,148 +0,0 @@ -<template> -<div class="mk-poll" :data-done="closed || isVoted"> - <ul> - <li v-for="(choice, i) in poll.choices" :key="i" @click="vote(i)" :class="{ voted: choice.voted }" :title="!closed && !isVoted ? $t('vote-to').replace('{}', choice.text) : ''"> - <div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> - <span> - <template v-if="choice.isVoted"><fa icon="check"/></template> - <mfm :text="choice.text" :plain="true" :custom-emojis="note.emojis"/> - <span class="votes" v-if="showResult">({{ $t('vote-count').replace('{}', choice.votes) }})</span> - </span> - </li> - </ul> - <p> - <span>{{ $t('total-votes').replace('{}', total) }}</span> - <span> · </span> - <a v-if="!closed && !isVoted" @click="toggleShowResult">{{ showResult ? $t('vote') : $t('show-result') }}</a> - <span v-if="isVoted">{{ $t('voted') }}</span> - <span v-else-if="closed">{{ $t('closed') }}</span> - <span v-if="remaining > 0"> · {{ timer }}</span> - </p> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { sum } from '../../../../../prelude/array'; -export default Vue.extend({ - i18n: i18n('common/views/components/poll.vue'), - props: ['note'], - data() { - return { - remaining: -1, - showResult: false - }; - }, - computed: { - poll(): any { - return this.note.poll; - }, - total(): number { - return sum(this.poll.choices.map(x => x.votes)); - }, - closed(): boolean { - return !this.remaining; - }, - timer(): string { - return this.$t( - this.remaining > 86400 ? 'remaining-days' : - this.remaining > 3600 ? 'remaining-hours' : - this.remaining > 60 ? 'remaining-minutes' : 'remaining-seconds') - .replace('{s}', Math.floor(this.remaining % 60)) - .replace('{m}', Math.floor(this.remaining / 60) % 60) - .replace('{h}', Math.floor(this.remaining / 3600) % 24) - .replace('{d}', Math.floor(this.remaining / 86400)); - }, - isVoted(): boolean { - return !this.poll.multiple && this.poll.choices.some(c => c.isVoted); - } - }, - created() { - this.showResult = this.isVoted; - - if (this.note.poll.expiresAt) { - const update = () => { - if (this.remaining = Math.floor(Math.max(new Date(this.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000)) - requestAnimationFrame(update); - else - this.showResult = true; - }; - - update(); - } - }, - methods: { - toggleShowResult() { - this.showResult = !this.showResult; - }, - vote(id) { - if (this.closed || !this.poll.multiple && this.poll.choices.some(c => c.isVoted)) return; - this.$root.api('notes/polls/vote', { - noteId: this.note.id, - choice: id - }).then(() => { - if (!this.showResult) this.showResult = !this.poll.multiple; - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-poll - > ul - display block - margin 0 - padding 0 - list-style none - - > li - display block - margin 4px 0 - padding 4px 8px - width 100% - color var(--pollChoiceText) - border solid 1px var(--pollChoiceBorder) - border-radius 4px - overflow hidden - cursor pointer - - &:hover - background rgba(#000, 0.05) - - &:active - background rgba(#000, 0.1) - - > .backdrop - position absolute - top 0 - left 0 - height 100% - background var(--primary) - transition width 1s ease - - > span - > [data-icon] - margin-right 4px - - > .votes - margin-left 4px - - > p - color var(--text) - - a - color inherit - - &[data-done] - > ul > li - cursor default - - &:hover - background transparent - - &:active - background transparent - -</style> diff --git a/src/client/app/common/views/components/post-form-attaches.vue b/src/client/app/common/views/components/post-form-attaches.vue deleted file mode 100644 index e051b6a808..0000000000 --- a/src/client/app/common/views/components/post-form-attaches.vue +++ /dev/null @@ -1,139 +0,0 @@ -<template> -<div class="skeikyzd" v-show="files.length != 0"> - <x-draggable class="files" :list="files" animation="150"> - <div v-for="file in files" :key="file.id" @click="showFileMenu(file, $event)" @contextmenu.prevent="showFileMenu(file, $event)"> - <x-file-thumbnail :data-id="file.id" class="thumbnail" :file="file" fit="cover"/> - <img class="remove" @click.stop="detachMedia(file.id)" src="/assets/desktop/remove.png" :title="$t('attach-cancel')" alt=""/> - <div class="sensitive" v-if="file.isSensitive"> - <fa class="icon" :icon="faExclamationTriangle"/> - </div> - </div> - </x-draggable> - <p class="remain">{{ 4 - files.length }}/4</p> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import * as XDraggable from 'vuedraggable'; -import XMenu from '../../../common/views/components/menu.vue'; -import { faTimesCircle, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; -import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; -import XFileThumbnail from './drive-file-thumbnail.vue' - -export default Vue.extend({ - i18n: i18n('common/views/components/post-form-attaches.vue'), - - components: { - XDraggable, - XFileThumbnail - }, - - props: { - files: { - type: Array, - required: true - }, - detachMediaFn: { - type: Function, - required: false - } - }, - - data() { - return { - faExclamationTriangle - }; - }, - - methods: { - detachMedia(id) { - if (this.detachMediaFn) this.detachMediaFn(id) - else if (this.$parent.detachMedia) this.$parent.detachMedia(id) - }, - toggleSensitive(file) { - this.$root.api('drive/files/update', { - fileId: file.id, - isSensitive: !file.isSensitive - }).then(() => { - file.isSensitive = !file.isSensitive; - }); - }, - showFileMenu(file, ev: MouseEvent) { - this.$root.new(XMenu, { - items: [{ - type: 'item', - text: file.isSensitive ? this.$t('unmark-as-sensitive') : this.$t('mark-as-sensitive'), - icon: file.isSensitive ? faEyeSlash : faEye, - action: () => { this.toggleSensitive(file) } - }, { - type: 'item', - text: this.$t('attach-cancel'), - icon: faTimesCircle, - action: () => { this.detachMedia(file.id) } - }], - source: ev.currentTarget || ev.target - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.skeikyzd - padding 4px - - > .files - display flex - flex-wrap wrap - - > div - width 64px - height 64px - margin 4px - cursor move - - &:hover > .remove - display block - - > .thumbnail - width 100% - height 100% - z-index 1 - color var(--text) - - > .remove - display none - position absolute - top -6px - right -6px - width 16px - height 16px - cursor pointer - z-index 1000 - - > .sensitive - display flex - position absolute - width 64px - height 64px - top 0 - left 0 - z-index 2 - background rgba(17, 17, 17, .7) - color #fff - - > .icon - margin auto - - > .remain - display block - position absolute - top 8px - right 8px - margin 0 - padding 0 - color var(--primaryAlpha04) - -</style> diff --git a/src/client/app/common/views/components/reaction-icon.vue b/src/client/app/common/views/components/reaction-icon.vue deleted file mode 100644 index afe51d7833..0000000000 --- a/src/client/app/common/views/components/reaction-icon.vue +++ /dev/null @@ -1,53 +0,0 @@ -<template> -<mk-emoji :emoji="str.startsWith(':') ? null : str" :name="str.startsWith(':') ? str.substr(1, str.length - 2) : null" :is-reaction="true" :custom-emojis="customEmojis" :normal="true"/> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -export default Vue.extend({ - i18n: i18n(), - props: { - reaction: { - type: String, - required: true - }, - }, - data() { - return { - customEmojis: [] - }; - }, - created() { - this.$root.getMeta().then(meta => { - if (meta && meta.emojis) this.customEmojis = meta.emojis; - }); - }, - computed: { - str(): any { - switch (this.reaction) { - case 'like': return '👍'; - case 'love': return '❤'; - case 'laugh': return '😆'; - case 'hmm': return '🤔'; - case 'surprise': return '😮'; - case 'congrats': return '🎉'; - case 'angry': return '💢'; - case 'confused': return '😥'; - case 'rip': return '😇'; - case 'pudding': return (this.$store.getters.isSignedIn && this.$store.state.settings.iLikeSushi) ? '🍣' : '🍮'; - case 'star': return '⭐'; - default: return this.reaction; - } - }, - }, -}); -</script> - -<style lang="stylus" scoped> -.mk-reaction-icon - img - vertical-align middle - width 1em - height 1em -</style> diff --git a/src/client/app/common/views/components/reaction-picker.vue b/src/client/app/common/views/components/reaction-picker.vue deleted file mode 100644 index f363fe9779..0000000000 --- a/src/client/app/common/views/components/reaction-picker.vue +++ /dev/null @@ -1,323 +0,0 @@ -<template> -<div class="rdfaahpb" v-hotkey.global="keymap"> - <div class="backdrop" ref="backdrop" @click="close"></div> - <div class="popover" :class="{ isMobile: $root.isMobile }" ref="popover"> - <p v-if="!$root.isMobile">{{ title }}</p> - <div class="buttons" ref="buttons" :class="{ showFocus }"> - <button v-for="(reaction, i) in rs" :key="reaction" @click="react(reaction)" @mouseover="onMouseover" @mouseout="onMouseout" :tabindex="i + 1" :title="/^[a-z]+$/.test(reaction) ? $t('@.reactions.' + reaction) : reaction" v-particle><mk-reaction-icon :reaction="reaction"/></button> - </div> - <div v-if="enableEmojiReaction" class="text"> - <input v-model="text" :placeholder="$t('input-reaction-placeholder')" @keyup.enter="reactText" @input="tryReactText" v-autocomplete="{ model: 'text' }"> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import anime from 'animejs'; -import { emojiRegex } from '../../../../../misc/emoji-regex'; - -export default Vue.extend({ - i18n: i18n('common/views/components/reaction-picker.vue'), - props: { - source: { - required: true - }, - - reactions: { - required: false - }, - - showFocus: { - type: Boolean, - required: false, - default: false - }, - - animation: { - type: Boolean, - required: false, - default: true - } - }, - - data() { - return { - rs: this.reactions || this.$store.state.settings.reactions, - title: this.$t('choose-reaction'), - text: null, - enableEmojiReaction: false, - focus: null - }; - }, - - computed: { - keymap(): any { - return { - 'esc': this.close, - 'enter|space|plus': this.choose, - 'up|k': this.focusUp, - 'left|h|shift+tab': this.focusLeft, - 'right|l|tab': this.focusRight, - 'down|j': this.focusDown, - '1': () => this.react('like'), - '2': () => this.react('love'), - '3': () => this.react('laugh'), - '4': () => this.react('hmm'), - '5': () => this.react('surprise'), - '6': () => this.react('congrats'), - '7': () => this.react('angry'), - '8': () => this.react('confused'), - '9': () => this.react('rip'), - '0': () => this.react('pudding'), - }; - } - }, - - watch: { - focus(i) { - this.$refs.buttons.children[i].focus(); - - if (this.showFocus) { - this.title = this.$refs.buttons.children[i].title; - } - } - }, - - mounted() { - this.$root.getMeta().then(meta => { - this.enableEmojiReaction = meta.enableEmojiReaction; - }); - - this.$nextTick(() => { - this.focus = 0; - - const popover = this.$refs.popover as any; - - const rect = this.source.getBoundingClientRect(); - const width = popover.offsetWidth; - const height = popover.offsetHeight; - - if (this.$root.isMobile) { - const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); - const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2); - popover.style.left = (x - (width / 2)) + 'px'; - popover.style.top = (y - (height / 2)) + 'px'; - } else { - const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); - const y = rect.top + window.pageYOffset + this.source.offsetHeight; - popover.style.left = (x - (width / 2)) + 'px'; - popover.style.top = y + 'px'; - } - - anime({ - targets: this.$refs.backdrop, - opacity: 1, - duration: this.animation ? 100 : 0, - easing: 'linear' - }); - - anime({ - targets: this.$refs.popover, - opacity: 1, - scale: [0.5, 1], - duration: this.animation ? 500 : 0 - }); - }); - }, - - methods: { - react(reaction) { - this.$emit('chosen', reaction); - }, - - reactText() { - if (!this.text) return; - this.react(this.text); - }, - - tryReactText() { - if (!this.text) return; - if (!this.text.match(emojiRegex)) return; - this.reactText(); - }, - - onMouseover(e) { - this.title = e.target.title; - }, - - onMouseout(e) { - this.title = this.$t('choose-reaction'); - }, - - close() { - (this.$refs.backdrop as any).style.pointerEvents = 'none'; - anime({ - targets: this.$refs.backdrop, - opacity: 0, - duration: this.animation ? 200 : 0, - easing: 'linear' - }); - - (this.$refs.popover as any).style.pointerEvents = 'none'; - anime({ - targets: this.$refs.popover, - opacity: 0, - scale: 0.5, - duration: this.animation ? 200 : 0, - easing: 'easeInBack', - complete: () => { - this.$emit('closed'); - this.destroyDom(); - } - }); - }, - - focusUp() { - this.focus = this.focus == 0 ? 9 : this.focus < 5 ? (this.focus + 4) : (this.focus - 5); - }, - - focusDown() { - this.focus = this.focus == 9 ? 0 : this.focus >= 5 ? (this.focus - 4) : (this.focus + 5); - }, - - focusRight() { - this.focus = this.focus == 9 ? 0 : (this.focus + 1); - }, - - focusLeft() { - this.focus = this.focus == 0 ? 9 : (this.focus - 1); - }, - - choose() { - this.$refs.buttons.childNodes[this.focus].click(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.rdfaahpb - position initial - - > .backdrop - position fixed - top 0 - left 0 - z-index 10000 - width 100% - height 100% - background var(--modalBackdrop) - opacity 0 - - > .popover - $bgcolor = var(--popupBg) - position absolute - z-index 10001 - background $bgcolor - border-radius 4px - box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) - transform scale(0.5) - opacity 0 - - &.isMobile - > div - width 280px - - > button - width 50px - height 50px - font-size 28px - border-radius 4px - - &:not(.isMobile) - $arrow-size = 16px - - margin-top $arrow-size - transform-origin center -($arrow-size) - - &:before - content "" - display block - position absolute - top -($arrow-size * 2) - left s('calc(50% - %s)', $arrow-size) - border-top solid $arrow-size transparent - border-left solid $arrow-size transparent - border-right solid $arrow-size transparent - border-bottom solid $arrow-size $bgcolor - - > p - display block - margin 0 - padding 8px 10px - font-size 14px - color var(--popupFg) - border-bottom solid var(--lineWidth) var(--faceDivider) - line-height 20px - - > .buttons - padding 4px 4px 8px 4px - width 216px - text-align center - - &.showFocus - > button:focus - z-index 1 - - &:after - content "" - pointer-events none - position absolute - top 0 - right 0 - bottom 0 - left 0 - border 2px solid var(--primaryAlpha03) - border-radius 4px - - > button - padding 0 - width 40px - height 40px - font-size 24px - border-radius 2px - - > * - height 1em - - &:hover - background var(--reactionPickerButtonHoverBg) - - &:active - background var(--primary) - box-shadow inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15) - - > .text - width 216px - padding 0 8px 8px 8px - - > input - width 100% - padding 10px - margin 0 - text-align center - font-size 16px - color var(--desktopPostFormTextareaFg) - background var(--desktopPostFormTextareaBg) - outline none - border solid 1px var(--primaryAlpha01) - border-radius 4px - transition border-color .2s ease - - &:hover - border-color var(--primaryAlpha02) - transition border-color .1s ease - - &:focus - border-color var(--primaryAlpha05) - transition border-color 0s ease - -</style> diff --git a/src/client/app/common/views/components/reactions-viewer.details.vue b/src/client/app/common/views/components/reactions-viewer.details.vue deleted file mode 100644 index 778b936896..0000000000 --- a/src/client/app/common/views/components/reactions-viewer.details.vue +++ /dev/null @@ -1,122 +0,0 @@ -<template> -<transition name="zoom-in-top"> - <div class="buebdbiu" ref="popover" v-if="show"> - <i18n path="few-users" v-if="users.length <= 10"> - <span slot="users"> - <b v-for="u in users" :key="u.id" style="margin-right: 12px;"> - <mk-avatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/> - <mk-user-name :user="u" :nowrap="false" style="line-height: 24px;"/> - </b> - </span> - <mk-reaction-icon slot="reaction" :reaction="reaction" ref="icon" /> - </i18n> - <i18n path="many-users" v-if="10 < users.length"> - <span slot="users"> - <b v-for="u in users" :key="u.id" style="margin-right: 12px;"> - <mk-avatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/> - <mk-user-name :user="u" :nowrap="false" style="line-height: 24px;"/> - </b> - </span> - <span slot="omitted">{{ count - 10 }}</span> - <mk-reaction-icon slot="reaction" :reaction="reaction" ref="icon" /> - </i18n> - </div> -</transition> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('common/views/components/reactions-viewer.details.vue'), - props: { - reaction: { - type: String, - required: true, - }, - users: { - type: Array, - required: true, - }, - count: { - type: Number, - required: true, - }, - source: { - required: true, - } - }, - data() { - return { - show: false - }; - }, - mounted() { - this.show = true; - - this.$nextTick(() => { - const popover = this.$refs.popover as any; - - if (this.source == null) { - this.destroyDom(); - return; - } - const rect = this.source.getBoundingClientRect(); - - const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); - const y = rect.top + window.pageYOffset + this.source.offsetHeight; - popover.style.left = (x - 28) + 'px'; - popover.style.top = (y + 16) + 'px'; - }); - } - methods: { - close() { - this.show = false; - setTimeout(this.destroyDom, 300); - } - } -}) -</script> - -<style lang="stylus" scoped> -.buebdbiu - $bgcolor = var(--popupBg) - z-index 10000 - display block - position absolute - max-width 240px - font-size 0.8em - padding 6px 8px - background $bgcolor - text-align center - color var(--text) - border-radius 4px - box-shadow 0 var(--lineWidth) 4px rgba(#000, 0.25) - pointer-events none - transform-origin center -16px - - &:before - content "" - pointer-events none - display block - position absolute - top -28px - left 12px - border-top solid 14px transparent - border-right solid 14px transparent - border-bottom solid 14px rgba(#000, 0.1) - border-left solid 14px transparent - - &:after - content "" - pointer-events none - display block - position absolute - top -27px - left 12px - border-top solid 14px transparent - border-right solid 14px transparent - border-bottom solid 14px $bgcolor - border-left solid 14px transparent -</style> diff --git a/src/client/app/common/views/components/renote.vue b/src/client/app/common/views/components/renote.vue deleted file mode 100644 index 58a0a26593..0000000000 --- a/src/client/app/common/views/components/renote.vue +++ /dev/null @@ -1,104 +0,0 @@ -<template> -<div class="puqkfets" :class="{ mini: narrow }"> - <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"/> - </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 - } - }, - inject: { - narrow: { - default: false - } - }, -}); -</script> - -<style lang="stylus" scoped> -.puqkfets - display flex - align-items center - padding 8px 16px - line-height 28px - white-space pre - color var(--renoteText) - background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%) - - &:not(.mini) - padding 8px 16px - - @media (min-width 500px) - padding 8px 16px - - @media (min-width 600px) - padding 16px 32px 8px 32px - - > .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/settings/2fa.vue b/src/client/app/common/views/components/settings/2fa.vue deleted file mode 100644 index 813a91b5c0..0000000000 --- a/src/client/app/common/views/components/settings/2fa.vue +++ /dev/null @@ -1,259 +0,0 @@ -<template> -<div class="2fa totp-section"> - <p style="margin-top:0;">{{ $t('intro') }}<a :href="$t('url')" target="_blank">{{ $t('detail') }}</a></p> - <ui-info warn>{{ $t('caution') }}</ui-info> - <p v-if="!data && !$store.state.i.twoFactorEnabled"><ui-button @click="register">{{ $t('register') }}</ui-button></p> - <template v-if="$store.state.i.twoFactorEnabled"> - <h2 class="heading">{{ $t('totp-header') }}</h2> - <p>{{ $t('already-registered') }}</p> - <ui-button @click="unregister">{{ $t('unregister') }}</ui-button> - - <template v-if="supportsCredentials"> - <hr class="totp-method-sep"> - - <h2 class="heading">{{ $t('security-key-header') }}</h2> - <p>{{ $t('security-key') }}</p> - <div class="key-list"> - <div class="key" v-for="key in $store.state.i.securityKeysList"> - <h3> - {{ key.name }} - </h3> - <div class="last-used"> - {{ $t('last-used') }} - <mk-time :time="key.lastUsed"/> - </div> - <ui-button @click="unregisterKey(key)"> - {{ $t('unregister') }} - </ui-button> - </div> - </div> - - <ui-switch v-model="usePasswordLessLogin" @change="updatePasswordLessLogin" v-if="$store.state.i.securityKeysList.length > 0"> - {{ $t('use-password-less-login') }} - </ui-switch> - - <ui-info warn v-if="registration && registration.error">{{ $t('something-went-wrong') }} {{ registration.error }}</ui-info> - <ui-button v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('register') }}</ui-button> - - <ol v-if="registration && !registration.error"> - <li v-if="registration.stage >= 0"> - {{ $t('activate-key') }} - <fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 0" /> - </li> - <li v-if="registration.stage >= 1"> - <ui-form :disabled="registration.stage != 1 || registration.saving"> - <ui-input v-model="keyName" :max="30"> - <span>{{ $t('security-key-name') }}</span> - </ui-input> - <ui-button @click="registerKey" :disabled="this.keyName.length == 0"> - {{ $t('register-security-key') }} - </ui-button> - <fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 1" /> - </ui-form> - </li> - </ol> - </template> - </template> - <div v-if="data && !$store.state.i.twoFactorEnabled"> - <ol> - <li>{{ $t('authenticator') }}<a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank">{{ $t('howtoinstall') }}</a></li> - <li>{{ $t('scan') }}<br><img :src="data.qr"></li> - <li>{{ $t('done') }}<br> - <ui-input v-model="token">{{ $t('token') }}</ui-input> - <ui-button primary @click="submit">{{ $t('submit') }}</ui-button> - </li> - </ol> - <ui-info>{{ $t('info') }}</ui-info> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; -import { hostname } from '../../../../config'; -import { hexifyAB } from '../../../scripts/2fa'; - -function stringifyAB(buffer) { - return String.fromCharCode.apply(null, new Uint8Array(buffer)); -} - -export default Vue.extend({ - i18n: i18n('desktop/views/components/settings.2fa.vue'), - data() { - return { - data: null, - supportsCredentials: !!navigator.credentials, - usePasswordLessLogin: this.$store.state.i.usePasswordLessLogin, - registration: null, - keyName: '', - token: null - }; - }, - methods: { - register() { - this.$root.dialog({ - title: this.$t('enter-password'), - input: { - type: 'password' - } - }).then(({ canceled, result: password }) => { - if (canceled) return; - this.$root.api('i/2fa/register', { - password: password - }).then(data => { - this.data = data; - }); - }); - }, - - unregister() { - this.$root.dialog({ - title: this.$t('enter-password'), - input: { - type: 'password' - } - }).then(({ canceled, result: password }) => { - if (canceled) return; - this.$root.api('i/2fa/unregister', { - password: password - }).then(() => { - this.usePasswordLessLogin = false; - this.updatePasswordLessLogin(); - }).then(() => { - this.$notify(this.$t('unregistered')); - this.$store.state.i.twoFactorEnabled = false; - }); - }); - }, - - submit() { - this.$root.api('i/2fa/done', { - token: this.token - }).then(() => { - this.$notify(this.$t('success')); - this.$store.state.i.twoFactorEnabled = true; - }).catch(() => { - this.$notify(this.$t('failed')); - }); - }, - - registerKey() { - this.registration.saving = true; - this.$root.api('i/2fa/key-done', { - password: this.registration.password, - name: this.keyName, - challengeId: this.registration.challengeId, - // we convert each 16 bits to a string to serialise - clientDataJSON: stringifyAB(this.registration.credential.response.clientDataJSON), - attestationObject: hexifyAB(this.registration.credential.response.attestationObject) - }).then(key => { - this.registration = null; - key.lastUsed = new Date(); - this.$notify(this.$t('success')); - }) - }, - - unregisterKey(key) { - this.$root.dialog({ - title: this.$t('enter-password'), - input: { - type: 'password' - } - }).then(({ canceled, result: password }) => { - if (canceled) return; - return this.$root.api('i/2fa/remove-key', { - password, - credentialId: key.id - }).then(() => { - this.usePasswordLessLogin = false; - this.updatePasswordLessLogin(); - }).then(() => { - this.$notify(this.$t('key-unregistered')); - }); - }); - }, - - addSecurityKey() { - this.$root.dialog({ - title: this.$t('enter-password'), - input: { - type: 'password' - } - }).then(({ canceled, result: password }) => { - if (canceled) return; - this.$root.api('i/2fa/register-key', { - password - }).then(registration => { - this.registration = { - password, - challengeId: registration.challengeId, - stage: 0, - publicKeyOptions: { - challenge: Buffer.from( - registration.challenge - .replace(/\-/g, "+") - .replace(/_/g, "/"), - 'base64' - ), - rp: { - id: hostname, - name: 'Misskey' - }, - user: { - id: Uint8Array.from(this.$store.state.i.id, c => c.charCodeAt(0)), - name: this.$store.state.i.username, - displayName: this.$store.state.i.name, - }, - pubKeyCredParams: [{alg: -7, type: 'public-key'}], - timeout: 60000, - attestation: 'direct' - }, - saving: true - }; - return navigator.credentials.create({ - publicKey: this.registration.publicKeyOptions - }); - }).then(credential => { - this.registration.credential = credential; - this.registration.saving = false; - this.registration.stage = 1; - }).catch(err => { - console.warn('Error while registering?', err); - this.registration.error = err.message; - this.registration.stage = -1; - }); - }); - }, - updatePasswordLessLogin() { - this.$root.api('i/2fa/password-less', { - value: !!this.usePasswordLessLogin - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.totp-section - .totp-method-sep - margin 1.5em 0 1em - border none - border-top solid var(--lineWidth) var(--faceDivider) - - h2.heading - margin 0 - - .key - padding 1em - margin 0.5em 0 - background #161616 - border-radius 6px - - h3 - margin-top 0 - margin-bottom .3em - - .last-used - margin-bottom .5em -</style> diff --git a/src/client/app/common/views/components/settings/api.vue b/src/client/app/common/views/components/settings/api.vue deleted file mode 100644 index 184fa069fb..0000000000 --- a/src/client/app/common/views/components/settings/api.vue +++ /dev/null @@ -1,102 +0,0 @@ -<template> -<ui-card> - <template #title><fa icon="key"/> API</template> - - <section class="fit-top"> - <ui-input :value="$store.state.i.token" readonly> - <span>{{ $t('token') }}</span> - </ui-input> - <p>{{ $t('intro') }}</p> - <ui-info warn>{{ $t('caution') }}</ui-info> - <p>{{ $t('regeneration-of-token') }}</p> - <ui-button @click="regenerateToken"><fa icon="sync-alt"/> {{ $t('regenerate-token') }}</ui-button> - </section> - - <section> - <header><fa icon="terminal"/> {{ $t('console.title') }}</header> - <ui-input v-model="endpoint" :datalist="endpoints" @change="onEndpointChange()"> - <span>{{ $t('console.endpoint') }}</span> - </ui-input> - <ui-textarea v-model="body"> - <span>{{ $t('console.parameter') }} (JSON or JSON5)</span> - <template #desc>{{ $t('console.credential-info') }}</template> - </ui-textarea> - <ui-button @click="send" :disabled="sending"> - <template v-if="sending">{{ $t('console.sending') }}</template> - <template v-else><fa icon="paper-plane"/> {{ $t('console.send') }}</template> - </ui-button> - <ui-textarea v-if="res" v-model="res" readonly tall> - <span>{{ $t('console.response') }}</span> - </ui-textarea> - </section> -</ui-card> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; -import * as JSON5 from 'json5'; - -export default Vue.extend({ - i18n: i18n('common/views/components/api-settings.vue'), - - data() { - return { - endpoint: '', - body: '{}', - res: null, - sending: false, - endpoints: [] - }; - }, - - created() { - this.$root.api('endpoints').then(endpoints => { - this.endpoints = endpoints; - }); - }, - - methods: { - regenerateToken() { - this.$root.dialog({ - title: this.$t('enter-password'), - input: { - type: 'password' - } - }).then(({ canceled, result: password }) => { - if (canceled) return; - this.$root.api('i/regenerate_token', { - password: password - }); - }); - }, - - send() { - this.sending = true; - this.$root.api(this.endpoint, JSON5.parse(this.body)).then(res => { - this.sending = false; - this.res = JSON5.stringify(res, null, 2); - }, err => { - this.sending = false; - this.res = JSON5.stringify(err, null, 2); - }); - }, - - onEndpointChange() { - this.$root.api('endpoint', { endpoint: this.endpoint }).then(endpoint => { - const body = {}; - for (const p of endpoint.params) { - body[p.name] = - p.type === 'String' ? '' : - p.type === 'Number' ? 0 : - p.type === 'Boolean' ? false : - p.type === 'Array' ? [] : - p.type === 'Object' ? {} : - null; - } - this.body = JSON5.stringify(body, null, 2); - }); - } - } -}); -</script> diff --git a/src/client/app/common/views/components/settings/app-type.vue b/src/client/app/common/views/components/settings/app-type.vue deleted file mode 100644 index d163f1e746..0000000000 --- a/src/client/app/common/views/components/settings/app-type.vue +++ /dev/null @@ -1,53 +0,0 @@ -<template> -<ui-card> - <template #title><fa :icon="faMobileAlt"/> {{ $t('title') }}</template> - - <section class="fit-top"> - <p>{{ $t('intro') }}</p> - <ui-select v-model="appTypeForce" :placeholder="$t('intro')"> - <option v-for="x in ['auto', 'desktop', 'mobile']" :value="x" :key="x">{{ $t(`choices.${x}`) }}</option> - </ui-select> - <ui-info warn>{{ $t('info') }}</ui-info> - </section> -</ui-card> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; -import { faMobileAlt } from '@fortawesome/free-solid-svg-icons' - -export default Vue.extend({ - i18n: i18n('common/views/components/settings/app-type.vue'), - - data() { - return { - faMobileAlt - }; - }, - - computed: { - appTypeForce: { - get() { return this.$store.state.device.appTypeForce; }, - set(value) { - this.$store.commit('device/set', { key: 'appTypeForce', value }); - this.reload(); - } - }, - }, - - methods: { - reload() { - this.$root.dialog({ - type: 'warning', - text: this.$t('@.reload-to-apply-the-setting'), - showCancelButton: true - }).then(({ canceled }) => { - if (!canceled) { - location.reload(); - } - }); - }, - } -}); -</script> diff --git a/src/client/app/common/views/components/settings/apps.vue b/src/client/app/common/views/components/settings/apps.vue deleted file mode 100644 index c5beaa1fe2..0000000000 --- a/src/client/app/common/views/components/settings/apps.vue +++ /dev/null @@ -1,39 +0,0 @@ -<template> -<div class="root"> - <ui-info v-if="!fetching && apps.length == 0">{{ $t('no-apps') }}</ui-info> - <div class="apps" v-if="apps.length != 0"> - <div v-for="app in apps"> - <p><b>{{ app.name }}</b></p> - <p>{{ app.description }}</p> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; -export default Vue.extend({ - i18n: i18n('desktop/views/components/settings.apps.vue'), - data() { - return { - fetching: true, - apps: [] - }; - }, - mounted() { - this.$root.api('i/authorized_apps').then(apps => { - this.apps = apps; - this.fetching = false; - }); - } -}); -</script> - -<style lang="stylus" scoped> -.root - > .apps - > div - padding 16px 0 0 0 - border-bottom solid 1px #eee -</style> diff --git a/src/client/app/common/views/components/settings/drive.vue b/src/client/app/common/views/components/settings/drive.vue deleted file mode 100644 index da028e85ef..0000000000 --- a/src/client/app/common/views/components/settings/drive.vue +++ /dev/null @@ -1,209 +0,0 @@ -<template> -<ui-card> - <template #title><fa icon="cloud"/> {{ $t('@.drive') }}</template> - - <section v-if="!fetching" class="juakhbxthdewydyreaphkepoxgxvfogn"> - <div class="meter"><div :style="meterStyle"></div></div> - <p>{{ $t('max') }}: <b>{{ capacity | bytes }}</b> {{ $t('in-use') }}: <b>{{ usage | bytes }}</b></p> - </section> - - <section> - <header>{{ $t('stats') }}</header> - <div ref="chart" style="margin-bottom: -16px; margin-left: -8px; color: #000;"></div> - </section> - - <section> - <header>{{ $t('default-upload-folder') }}</header> - <ui-input v-model="uploadFolderName" readonly>{{ $t('default-upload-folder-name') }}</ui-input> - <ui-button @click="chooseUploadFolder()">{{ $t('change-default-upload-folder') }}</ui-button> - </section> -</ui-card> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; -import * as tinycolor from 'tinycolor2'; -import ApexCharts from 'apexcharts'; - -export default Vue.extend({ - i18n: i18n('common/views/components/drive-settings.vue'), - data() { - return { - fetching: true, - usage: null, - capacity: null, - uploadFolderName: null - }; - }, - - computed: { - meterStyle(): any { - return { - width: `${this.usage / this.capacity * 100}%`, - background: tinycolor({ - h: 180 - (this.usage / this.capacity * 180), - s: 0.7, - l: 0.5 - }) - }; - }, - - uploadFolder: { - get() { return this.$store.state.settings.uploadFolder; }, - set(value) { this.$store.dispatch('settings/set', { key: 'uploadFolder', value }); } - }, - }, - - mounted() { - if (this.uploadFolder == null) { - this.uploadFolderName = this.$t('@._settings.root'); - } else { - this.$root.api('drive/folders/show', { - folderId: this.uploadFolder - }).then(folder => { - this.uploadFolderName = folder.name; - }); - } - - this.$root.api('drive').then(info => { - this.capacity = info.capacity; - this.usage = info.usage; - this.fetching = false; - - this.$nextTick(() => { - this.renderChart(); - }); - }); - }, - - methods: { - renderChart() { - this.$root.api('charts/user/drive', { - userId: this.$store.state.i.id, - span: 'day', - limit: 21 - }).then(stats => { - const addition = []; - const deletion = []; - - const now = new Date(); - const y = now.getFullYear(); - const m = now.getMonth(); - const d = now.getDate(); - - for (let i = 0; i < 21; i++) { - const x = new Date(y, m, d - i); - addition.push([ - x, - stats.incSize[i] - ]); - deletion.push([ - x, - -stats.decSize[i] - ]); - } - - const chart = new ApexCharts(this.$refs.chart, { - chart: { - type: 'bar', - stacked: true, - height: 150, - zoom: { - enabled: false - }, - toolbar: { - show: false - } - }, - plotOptions: { - bar: { - columnWidth: '80%' - } - }, - grid: { - clipMarkers: false, - borderColor: 'rgba(0, 0, 0, 0.1)', - xaxis: { - lines: { - show: true, - } - }, - }, - tooltip: { - shared: true, - intersect: false - }, - dataLabels: { - enabled: false - }, - legend: { - show: false - }, - series: [{ - name: 'Additions', - data: addition - }, { - name: 'Deletions', - data: deletion - }], - xaxis: { - type: 'datetime', - labels: { - style: { - colors: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString() - } - }, - axisBorder: { - color: 'rgba(0, 0, 0, 0.1)' - }, - axisTicks: { - color: 'rgba(0, 0, 0, 0.1)' - }, - crosshairs: { - width: 1, - opacity: 1 - } - }, - yaxis: { - labels: { - formatter: v => Vue.filter('bytes')(v, 0), - style: { - color: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString() - } - } - } - }); - - chart.render(); - }); - }, - - chooseUploadFolder() { - this.$chooseDriveFolder().then(folder => { - this.uploadFolder = folder ? folder.id : null; - this.uploadFolderName = folder ? folder.name : this.$t('@._settings.root'); - }) - } - } -}); -</script> - -<style lang="stylus" scoped> -.juakhbxthdewydyreaphkepoxgxvfogn - > .meter - $size = 12px - - margin-bottom 16px - background rgba(0, 0, 0, 0.1) - border-radius ($size / 2) - overflow hidden - - > div - height $size - border-radius ($size / 2) - - > p - margin 0 - -</style> diff --git a/src/client/app/common/views/components/settings/integration.vue b/src/client/app/common/views/components/settings/integration.vue deleted file mode 100644 index 71ad8b4509..0000000000 --- a/src/client/app/common/views/components/settings/integration.vue +++ /dev/null @@ -1,118 +0,0 @@ -<template> -<ui-card v-if="enableTwitterIntegration || enableDiscordIntegration || enableGithubIntegration"> - <template #title><fa icon="share-alt"/> {{ $t('title') }}</template> - - <section v-if="enableTwitterIntegration"> - <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}`" rel="nofollow noopener" 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 v-if="enableDiscordIntegration"> - <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}`" rel="nofollow noopener" 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 v-if="enableGithubIntegration"> - <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}`" rel="nofollow noopener" 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, - enableTwitterIntegration: false, - enableDiscordIntegration: false, - enableGithubIntegration: false, - }; - }, - - created() { - this.$root.getMeta().then(meta => { - this.enableTwitterIntegration = meta.enableTwitterIntegration; - this.enableDiscordIntegration = meta.enableDiscordIntegration; - this.enableGithubIntegration = meta.enableGithubIntegration; - }); - }, - - mounted() { - if (!document.cookie.match(/i=(\w+)/)) { - document.cookie = `i=${this.$store.state.i.token}; path=/;` + - ` domain=${document.location.hostname}; max-age=31536000;` + - (document.location.protocol.startsWith('https') ? ' secure' : ''); - } - 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/settings/language.vue b/src/client/app/common/views/components/settings/language.vue deleted file mode 100644 index f81775f09b..0000000000 --- a/src/client/app/common/views/components/settings/language.vue +++ /dev/null @@ -1,54 +0,0 @@ -<template> -<ui-card> - <template #title><fa icon="language"/> {{ $t('title') }}</template> - - <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/settings/mute-and-block.user.vue b/src/client/app/common/views/components/settings/mute-and-block.user.vue deleted file mode 100644 index 29ef1f7a67..0000000000 --- a/src/client/app/common/views/components/settings/mute-and-block.user.vue +++ /dev/null @@ -1,39 +0,0 @@ -<template> -<div class="muteblockuser"> - <div class="avatar-link"> - <a :href="user | userPage(null, true)"> - <mk-avatar class="avatar" :user="user" :disable-link="true"/> - </a> - </div> - <div class="text"> - <div><mk-user-name :user="user"/></div> - <div class="username">@{{ user | acct }}</div> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; - -export default Vue.extend({ - i18n: i18n('common/views/components/mute-and-block.user.vue'), - props: ['user'], -}); -</script> - -<style lang="stylus" scoped> -.muteblockuser - display flex - padding 16px - - > .avatar-link - > a - > .avatar - width 40px - height 40px - - > .text - color var(--text) - margin-left 16px -</style> diff --git a/src/client/app/common/views/components/settings/mute-and-block.vue b/src/client/app/common/views/components/settings/mute-and-block.vue deleted file mode 100644 index 8ff5804168..0000000000 --- a/src/client/app/common/views/components/settings/mute-and-block.vue +++ /dev/null @@ -1,181 +0,0 @@ -<template> -<ui-card> - <template #title><fa icon="ban"/> {{ $t('mute-and-block') }}</template> - - <section> - <header>{{ $t('mute') }}</header> - <ui-info v-if="!muteFetching && mute.length == 0">{{ $t('no-muted-users') }}</ui-info> - <div class="users" v-if="mute.length != 0"> - <div class="user" v-for="user in mute" :key="user.id"> - <x-user :user="user"/> - <span @click="unmute(user)"> - <fa icon="times"/> - </span> - </div> - <ui-button v-if="this.muteCursor != null" @click="updateMute()">{{ $t('@.load-more') }}</ui-button> - </div> - </section> - - <section> - <header>{{ $t('block') }}</header> - <ui-info v-if="!blockFetching && block.length == 0">{{ $t('no-blocked-users') }}</ui-info> - <div class="users" v-if="block.length != 0"> - <div class="user" v-for="user in block" :key="user.id"> - <x-user :user="user"/> - <span @click="unblock(user)"> - <fa icon="times"/> - </span> - </div> - <ui-button v-if="this.blockCursor != null" @click="updateBlock()">{{ $t('@.load-more') }}</ui-button> - </div> - </section> - - <section> - <header>{{ $t('word-mute') }}</header> - <ui-textarea v-model="mutedWords"> - {{ $t('muted-words') }}<template #desc>{{ $t('muted-words-description') }}</template> - </ui-textarea> - <ui-button @click="save">{{ $t('save') }}</ui-button> - </section> -</ui-card> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; -import XUser from './mute-and-block.user.vue'; - -const fetchLimit = 30; - -export default Vue.extend({ - i18n: i18n('common/views/components/mute-and-block.vue'), - - components: { - XUser - }, - - data() { - return { - muteFetching: true, - blockFetching: true, - mute: [], - block: [], - muteCursor: undefined, - blockCursor: undefined, - mutedWords: '' - }; - }, - - computed: { - _mutedWords: { - get() { return this.$store.state.settings.mutedWords; }, - set(value) { this.$store.dispatch('settings/set', { key: 'mutedWords', value }); } - }, - }, - - mounted() { - this.mutedWords = this._mutedWords.map(words => words.join(' ')).join('\n'); - - this.updateMute(); - this.updateBlock(); - }, - - methods: { - save() { - this._mutedWords = this.mutedWords.split('\n').map(line => line.split(' ').filter(x => x != '')); - }, - - unmute(user) { - this.$root.dialog({ - type: 'warning', - text: this.$t('unmute-confirm'), - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) return; - this.$root.api('mute/delete', { - userId: user.id - }).then(() => { - this.muteCursor = undefined; - this.updateMute(); - }); - }); - }, - - unblock(user) { - this.$root.dialog({ - type: 'warning', - text: this.$t('unblock-confirm'), - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) return; - this.$root.api('blocking/delete', { - userId: user.id - }).then(() => { - this.updateBlock(); - }); - }); - }, - - updateMute() { - this.muteFetching = true; - this.$root.api('mute/list', { - limit: fetchLimit + 1, - untilId: this.muteCursor, - }).then((items: Object[]) => { - const past = this.muteCursor ? this.mute : []; - - if (items.length === fetchLimit + 1) { - items.pop() - this.muteCursor = items[items.length - 1].id; - } else { - this.muteCursor = undefined; - } - - this.mute = past.concat(items.map(x => x.mutee)); - this.muteFetching = false; - }); - }, - - updateBlock() { - this.blockFetching = true; - this.$root.api('blocking/list', { - limit: fetchLimit + 1, - untilId: this.blockCursor, - }).then((items: Object[]) => { - const past = this.blockCursor ? this.block : []; - - if (items.length === fetchLimit + 1) { - items.pop() - this.blockCursor = items[items.length - 1].id; - } else { - this.blockCursor = undefined; - } - - this.block = past.concat(items.map(x => x.blockee)); - this.blockFetching = false; - }); - }, - } -}); -</script> - -<style lang="stylus" scoped> - .users - > .user - display flex - align-items center - justify-content flex-end - border-radius 6px - - &:hover - background-color var(--primary) - - > span - margin-left auto - cursor pointer - padding 16px - - > button - margin-top 16px -</style> - diff --git a/src/client/app/common/views/components/settings/notification.vue b/src/client/app/common/views/components/settings/notification.vue deleted file mode 100644 index 2554fe6331..0000000000 --- a/src/client/app/common/views/components/settings/notification.vue +++ /dev/null @@ -1,44 +0,0 @@ -<template> -<ui-card> - <template #title><fa :icon="['far', 'bell']"/> {{ $t('title') }}</template> - <section> - <ui-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch"> - {{ $t('auto-watch') }}<template #desc>{{ $t('auto-watch-desc') }}</template> - </ui-switch> - <section> - <ui-button @click="readAllNotifications">{{ $t('mark-as-read-all-notifications') }}</ui-button> - <ui-button @click="readAllUnreadNotes">{{ $t('mark-as-read-all-unread-notes') }}</ui-button> - <ui-button @click="readAllMessagingMessages">{{ $t('mark-as-read-all-talk-messages') }}</ui-button> - </section> - </section> -</ui-card> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; - -export default Vue.extend({ - i18n: i18n('common/views/components/notification-settings.vue'), - - methods: { - onChangeAutoWatch(v) { - this.$root.api('i/update', { - autoWatch: v - }); - }, - - readAllUnreadNotes() { - this.$root.api('i/read_all_unread_notes'); - }, - - readAllMessagingMessages() { - this.$root.api('i/read_all_messaging_messages'); - }, - - readAllNotifications() { - this.$root.api('notifications/mark_all_as_read'); - } - } -}); -</script> diff --git a/src/client/app/common/views/components/settings/password.vue b/src/client/app/common/views/components/settings/password.vue deleted file mode 100644 index c867561518..0000000000 --- a/src/client/app/common/views/components/settings/password.vue +++ /dev/null @@ -1,63 +0,0 @@ -<template> -<div> - <ui-button @click="reset">{{ $t('reset') }}</ui-button> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; - -export default Vue.extend({ - i18n: i18n('common/views/components/password-settings.vue'), - methods: { - async reset() { - const { canceled: canceled1, result: currentPassword } = await this.$root.dialog({ - title: this.$t('enter-current-password'), - input: { - type: 'password' - } - }); - 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') - }); - }); - } - } -}); -</script> diff --git a/src/client/app/common/views/components/settings/profile.vue b/src/client/app/common/views/components/settings/profile.vue deleted file mode 100644 index 0c291f9029..0000000000 --- a/src/client/app/common/views/components/settings/profile.vue +++ /dev/null @@ -1,442 +0,0 @@ -<template> -<ui-card> - <template #title><fa icon="user"/> {{ $t('title') }}</template> - - <section class="esokaraujimuwfttfzgocmutcihewscl"> - <div class="header" :style="bannerStyle"> - <mk-avatar class="avatar" :user="$store.state.i" :disable-preview="true" :disable-link="true"/> - </div> - - <ui-form :disabled="saving"> - <ui-input v-model="name" :max="30"> - <span>{{ $t('name') }}</span> - </ui-input> - - <ui-input v-model="username" readonly> - <span>{{ $t('account') }}</span> - <template #prefix>@</template> - <template #suffix>@{{ host }}</template> - </ui-input> - - <ui-input v-model="location"> - <span>{{ $t('location') }}</span> - <template #prefix><fa icon="map-marker-alt"/></template> - </ui-input> - - <ui-input v-model="birthday" type="date"> - <template #title>{{ $t('birthday') }}</template> - <template #prefix><fa icon="birthday-cake"/></template> - </ui-input> - - <ui-textarea v-model="description" :max="500"> - <span>{{ $t('description') }}</span> - <template #desc>{{ $t('you-can-include-hashtags') }}</template> - </ui-textarea> - - <ui-select v-model="lang"> - <template #label>{{ $t('language') }}</template> - <template #icon><fa icon="language"/></template> - <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> - <template #icon><fa icon="image"/></template> - <template #desc v-if="avatarUploading">{{ $t('uploading') }}<mk-ellipsis/></template> - </ui-input> - - <ui-input type="file" @change="onBannerChange"> - <span>{{ $t('banner') }}</span> - <template #icon><fa icon="image"/></template> - <template #desc v-if="bannerUploading">{{ $t('uploading') }}<mk-ellipsis/></template> - </ui-input> - - <div class="fields"> - <header>{{ $t('profile-metadata') }}</header> - <ui-horizon-group> - <ui-input v-model="fieldName0">{{ $t('metadata-label') }}</ui-input> - <ui-input v-model="fieldValue0">{{ $t('metadata-content') }}</ui-input> - </ui-horizon-group> - <ui-horizon-group> - <ui-input v-model="fieldName1">{{ $t('metadata-label') }}</ui-input> - <ui-input v-model="fieldValue1">{{ $t('metadata-content') }}</ui-input> - </ui-horizon-group> - <ui-horizon-group> - <ui-input v-model="fieldName2">{{ $t('metadata-label') }}</ui-input> - <ui-input v-model="fieldValue2">{{ $t('metadata-content') }}</ui-input> - </ui-horizon-group> - <ui-horizon-group> - <ui-input v-model="fieldName3">{{ $t('metadata-label') }}</ui-input> - <ui-input v-model="fieldValue3">{{ $t('metadata-content') }}</ui-input> - </ui-horizon-group> - </div> - - <ui-button @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</ui-button> - </ui-form> - </section> - - <section> - <header><fa :icon="faCogs"/> {{ $t('advanced') }}</header> - - <div> - <ui-switch v-model="isCat" @change="save(false)">{{ $t('is-cat') }}</ui-switch> - <ui-switch v-model="isBot" @change="save(false)">{{ $t('is-bot') }}</ui-switch> - <ui-switch v-model="alwaysMarkNsfw">{{ $t('@._settings.always-mark-nsfw') }}</ui-switch> - </div> - </section> - - <section> - <header><fa :icon="faUnlockAlt"/> {{ $t('privacy') }}</header> - - <div> - <ui-switch v-model="isLocked" @change="save(false)">{{ $t('is-locked') }}</ui-switch> - <ui-switch v-model="carefulBot" :disabled="isLocked" @change="save(false)">{{ $t('careful-bot') }}</ui-switch> - <ui-switch v-model="autoAcceptFollowed" :disabled="!isLocked && !carefulBot" @change="save(false)">{{ $t('auto-accept-followed') }}</ui-switch> - </div> - </section> - - <section v-if="enableEmail"> - <header><fa :icon="faEnvelope"/> {{ $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()" :disabled="email === $store.state.i.email"><fa :icon="faSave"/> {{ $t('save') }}</ui-button> - </div> - </section> - - <section> - <header><fa :icon="faBoxes"/> {{ $t('export-and-import') }}</header> - - <div> - <ui-select v-model="exportTarget"> - <option value="notes">{{ $t('export-targets.all-notes') }}</option> - <option value="following">{{ $t('export-targets.following-list') }}</option> - <option value="mute">{{ $t('export-targets.mute-list') }}</option> - <option value="blocking">{{ $t('export-targets.blocking-list') }}</option> - <option value="user-lists">{{ $t('export-targets.user-lists') }}</option> - </ui-select> - <ui-horizon-group class="fit-bottom"> - <ui-button @click="doExport()"><fa :icon="faDownload"/> {{ $t('export') }}</ui-button> - <ui-button @click="doImport()" :disabled="!['following', 'user-lists'].includes(exportTarget)"><fa :icon="faUpload"/> {{ $t('import') }}</ui-button> - </ui-horizon-group> - </div> - </section> - - <section> - <details> - <summary>{{ $t('danger-zone') }}</summary> - <ui-button @click="deleteAccount()">{{ $t('delete-account') }}</ui-button> - </details> - </section> -</ui-card> -</template> - -<script lang="ts"> -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'; -import { faDownload, faUpload, faUnlockAlt, faBoxes, faCogs } from '@fortawesome/free-solid-svg-icons'; -import { faSave, faEnvelope } from '@fortawesome/free-regular-svg-icons'; - -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, - fieldName0: null, - fieldValue0: null, - fieldName1: null, - fieldValue1: null, - fieldName2: null, - fieldValue2: null, - fieldName3: null, - fieldValue3: null, - lang: null, - birthday: null, - avatarId: null, - bannerId: null, - isCat: false, - isBot: false, - isLocked: false, - carefulBot: false, - autoAcceptFollowed: false, - saving: false, - avatarUploading: false, - bannerUploading: false, - exportTarget: 'notes', - faDownload, faUpload, faSave, faEnvelope, faUnlockAlt, faBoxes, faCogs - }; - }, - - computed: { - alwaysMarkNsfw: { - get() { return this.$store.state.i.alwaysMarkNsfw; }, - set(value) { this.$root.api('i/update', { alwaysMarkNsfw: value }); } - }, - - bannerStyle(): any { - if (this.$store.state.i.bannerUrl == null) return {}; - return { - backgroundColor: this.$store.state.i.bannerColor, - backgroundImage: `url(${ this.$store.state.i.bannerUrl })` - }; - }, - }, - - created() { - 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.location; - this.description = this.$store.state.i.description; - this.lang = this.$store.state.i.lang; - this.birthday = this.$store.state.i.birthday; - this.avatarId = this.$store.state.i.avatarId; - this.bannerId = this.$store.state.i.bannerId; - this.isCat = this.$store.state.i.isCat; - this.isBot = this.$store.state.i.isBot; - this.isLocked = this.$store.state.i.isLocked; - this.carefulBot = this.$store.state.i.carefulBot; - this.autoAcceptFollowed = this.$store.state.i.autoAcceptFollowed; - - this.fieldName0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].name : null; - this.fieldValue0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].value : null; - this.fieldName1 = this.$store.state.i.fields[1] ? this.$store.state.i.fields[1].name : null; - this.fieldValue1 = this.$store.state.i.fields[1] ? this.$store.state.i.fields[1].value : null; - this.fieldName2 = this.$store.state.i.fields[2] ? this.$store.state.i.fields[2].name : null; - this.fieldValue2 = this.$store.state.i.fields[2] ? this.$store.state.i.fields[2].value : null; - this.fieldName3 = this.$store.state.i.fields[3] ? this.$store.state.i.fields[3].name : null; - this.fieldValue3 = this.$store.state.i.fields[3] ? this.$store.state.i.fields[3].value : null; - }, - - methods: { - onAvatarChange([file]) { - this.avatarUploading = true; - - const data = new FormData(); - data.append('file', file); - data.append('i', this.$store.state.i.token); - - fetch(apiUrl + '/drive/files/create', { - method: 'POST', - body: data - }) - .then(response => response.json()) - .then(f => { - this.avatarId = f.id; - this.avatarUploading = false; - }) - .catch(e => { - this.avatarUploading = false; - alert('%18n:@upload-failed%'); - }); - }, - - onBannerChange([file]) { - this.bannerUploading = true; - - const data = new FormData(); - data.append('file', file); - data.append('i', this.$store.state.i.token); - - fetch(apiUrl + '/drive/files/create', { - method: 'POST', - body: data - }) - .then(response => response.json()) - .then(f => { - this.bannerId = f.id; - this.bannerUploading = false; - }) - .catch(e => { - this.bannerUploading = false; - alert('%18n:@upload-failed%'); - }); - }, - - save(notify) { - const fields = [ - { name: this.fieldName0, value: this.fieldValue0 }, - { name: this.fieldName1, value: this.fieldValue1 }, - { name: this.fieldName2, value: this.fieldValue2 }, - { name: this.fieldName3, value: this.fieldValue3 }, - ]; - - this.saving = true; - - this.$root.api('i/update', { - name: this.name || null, - location: this.location || null, - description: this.description || null, - lang: this.lang, - birthday: this.birthday || null, - avatarId: this.avatarId || undefined, - bannerId: this.bannerId || undefined, - fields, - isCat: !!this.isCat, - isBot: !!this.isBot, - isLocked: !!this.isLocked, - carefulBot: !!this.carefulBot, - autoAcceptFollowed: !!this.autoAcceptFollowed - }).then(i => { - this.saving = false; - this.$store.state.i.avatarId = i.avatarId; - this.$store.state.i.avatarUrl = i.avatarUrl; - this.$store.state.i.bannerId = i.bannerId; - this.$store.state.i.bannerUrl = i.bannerUrl; - - if (notify) { - this.$root.dialog({ - type: 'success', - text: this.$t('saved') - }); - } - }).catch(err => { - this.saving = false; - switch(err.id) { - case 'f419f9f8-2f4d-46b1-9fb4-49d3a2fd7191': - this.$root.dialog({ - type: 'error', - title: this.$t('unable-to-process'), - text: this.$t('avatar-not-an-image') - }); - break; - case '75aedb19-2afd-4e6d-87fc-67941256fa60': - this.$root.dialog({ - type: 'error', - title: this.$t('unable-to-process'), - text: this.$t('banner-not-an-image') - }); - break; - default: - this.$root.dialog({ - type: 'error', - text: this.$t('unable-to-process') - }); - } - }); - }, - - 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 - }); - }); - }, - - doExport() { - this.$root.api( - this.exportTarget == 'notes' ? 'i/export-notes' : - this.exportTarget == 'following' ? 'i/export-following' : - this.exportTarget == 'mute' ? 'i/export-mute' : - this.exportTarget == 'blocking' ? 'i/export-blocking' : - this.exportTarget == 'user-lists' ? 'i/export-user-lists' : - null, {}).then(() => { - this.$root.dialog({ - type: 'info', - text: this.$t('export-requested') - }); - }).catch((e: any) => { - this.$root.dialog({ - type: 'error', - text: e.message - }); - }); - }, - - doImport() { - this.$chooseDriveFile().then(file => { - this.$root.api( - this.exportTarget == 'following' ? 'i/import-following' : - this.exportTarget == 'user-lists' ? 'i/import-user-lists' : - null, { - fileId: file.id - }).then(() => { - this.$root.dialog({ - type: 'info', - text: this.$t('import-requested') - }); - }).catch((e: any) => { - this.$root.dialog({ - type: 'error', - text: e.message - }); - }); - }); - }, - - async deleteAccount() { - const { canceled: canceled, result: password } = await this.$root.dialog({ - title: this.$t('enter-password'), - input: { - type: 'password' - } - }); - if (canceled) return; - - this.$root.api('i/delete-account', { - password - }).then(() => { - this.$root.dialog({ - type: 'success', - text: this.$t('account-deleted') - }); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.esokaraujimuwfttfzgocmutcihewscl - > .header - height 150px - overflow hidden - background-size cover - background-position center - border-radius 4px - - > .avatar - position absolute - top 0 - bottom 0 - left 0 - right 0 - display block - width 72px - height 72px - margin auto - -.fields - > header - padding 8px 0px - font-weight bold - -</style> diff --git a/src/client/app/common/views/components/settings/settings.vue b/src/client/app/common/views/components/settings/settings.vue deleted file mode 100644 index 3a0ba561af..0000000000 --- a/src/client/app/common/views/components/settings/settings.vue +++ /dev/null @@ -1,671 +0,0 @@ -<template> -<div class="nqfhvmnl"> - <template v-if="page == null || page == 'profile'"> - <x-profile/> - <x-integration/> - </template> - - <template v-if="page == null || page == 'appearance'"> - <x-theme/> - - <ui-card> - <template #title><fa icon="desktop"/> {{ $t('@._settings.appearance') }}</template> - - <section v-if="!$root.isMobile"> - <ui-switch v-model="showPostFormOnTopOfTl">{{ $t('@._settings.post-form-on-timeline') }}</ui-switch> - <ui-button @click="customizeHome">{{ $t('@.customize-home') }}</ui-button> - </section> - <section v-if="!$root.isMobile"> - <header>{{ $t('@._settings.wallpaper') }}</header> - <ui-horizon-group class="fit-bottom"> - <ui-button @click="updateWallpaper">{{ $t('@._settings.choose-wallpaper') }}</ui-button> - <ui-button @click="deleteWallpaper">{{ $t('@._settings.delete-wallpaper') }}</ui-button> - </ui-horizon-group> - </section> - <section v-if="!$root.isMobile"> - <header>{{ $t('@._settings.navbar-position') }}</header> - <ui-radio v-model="navbar" value="top">{{ $t('@._settings.navbar-position-top') }}</ui-radio> - <ui-radio v-model="navbar" value="left">{{ $t('@._settings.navbar-position-left') }}</ui-radio> - <ui-radio v-model="navbar" value="right">{{ $t('@._settings.navbar-position-right') }}</ui-radio> - </section> - <section> - <ui-switch v-model="useShadow">{{ $t('@._settings.use-shadow') }}</ui-switch> - <ui-switch v-model="roundedCorners">{{ $t('@._settings.rounded-corners') }}</ui-switch> - <ui-switch v-model="circleIcons">{{ $t('@._settings.circle-icons') }}</ui-switch> - <ui-switch v-model="reduceMotion">{{ $t('@._settings.reduce-motion') }}</ui-switch> - <ui-switch v-model="contrastedAcct">{{ $t('@._settings.contrasted-acct') }}</ui-switch> - <ui-switch v-model="showFullAcct">{{ $t('@._settings.show-full-acct') }}</ui-switch> - <ui-switch v-model="showVia">{{ $t('@._settings.show-via') }}</ui-switch> - <ui-switch v-model="useOsDefaultEmojis">{{ $t('@._settings.use-os-default-emojis') }}</ui-switch> - <ui-switch v-model="iLikeSushi">{{ $t('@._settings.i-like-sushi') }}</ui-switch> - </section> - <section> - <ui-switch v-model="suggestRecentHashtags">{{ $t('@._settings.suggest-recent-hashtags') }}</ui-switch> - <ui-switch v-model="showClockOnHeader" v-if="!$root.isMobile">{{ $t('@._settings.show-clock-on-header') }}</ui-switch> - <ui-switch v-model="alwaysShowNsfw">{{ $t('@._settings.always-show-nsfw') }}</ui-switch> - <ui-switch v-model="showReplyTarget">{{ $t('@._settings.show-reply-target') }}</ui-switch> - <ui-switch v-model="disableAnimatedMfm">{{ $t('@._settings.disable-animated-mfm') }}</ui-switch> - <ui-switch v-model="disableShowingAnimatedImages">{{ $t('@._settings.disable-showing-animated-images') }}</ui-switch> - <ui-switch v-model="remainDeletedNote">{{ $t('@._settings.remain-deleted-note') }}</ui-switch> - <ui-switch v-model="enableMobileQuickNotificationView">{{ $t('@._settings.enable-quick-notification-view') }}</ui-switch> - </section> - <section> - <header>{{ $t('@._settings.line-width') }}</header> - <ui-radio v-model="lineWidth" :value="0.5">{{ $t('@._settings.line-width-thin') }}</ui-radio> - <ui-radio v-model="lineWidth" :value="1">{{ $t('@._settings.line-width-normal') }}</ui-radio> - <ui-radio v-model="lineWidth" :value="2">{{ $t('@._settings.line-width-thick') }}</ui-radio> - </section> - <section> - <header>{{ $t('@._settings.font-size') }}</header> - <ui-radio v-model="fontSize" :value="-2">{{ $t('@._settings.font-size-x-small') }}</ui-radio> - <ui-radio v-model="fontSize" :value="-1">{{ $t('@._settings.font-size-small') }}</ui-radio> - <ui-radio v-model="fontSize" :value="0">{{ $t('@._settings.font-size-medium') }}</ui-radio> - <ui-radio v-model="fontSize" :value="1">{{ $t('@._settings.font-size-large') }}</ui-radio> - <ui-radio v-model="fontSize" :value="2">{{ $t('@._settings.font-size-x-large') }}</ui-radio> - </section> - <section v-if="$root.isMobile"> - <header>{{ $t('@._settings.post-style') }}</header> - <ui-radio v-model="postStyle" value="standard">{{ $t('@._settings.post-style-standard') }}</ui-radio> - <ui-radio v-model="postStyle" value="smart">{{ $t('@._settings.post-style-smart') }}</ui-radio> - </section> - <section v-if="$root.isMobile"> - <header>{{ $t('@._settings.notification-position') }}</header> - <ui-radio v-model="mobileNotificationPosition" value="bottom">{{ $t('@._settings.notification-position-bottom') }}</ui-radio> - <ui-radio v-model="mobileNotificationPosition" value="top">{{ $t('@._settings.notification-position-top') }}</ui-radio> - </section> - <section> - <header>{{ $t('@._settings.deck-column-align') }}</header> - <ui-radio v-model="deckColumnAlign" value="center">{{ $t('@._settings.deck-column-align-center') }}</ui-radio> - <ui-radio v-model="deckColumnAlign" value="left">{{ $t('@._settings.deck-column-align-left') }}</ui-radio> - <ui-radio v-model="deckColumnAlign" value="flexible">{{ $t('@._settings.deck-column-align-flexible') }}</ui-radio> - </section> - <section> - <header>{{ $t('@._settings.deck-column-width') }}</header> - <ui-radio v-model="deckColumnWidth" value="narrow">{{ $t('@._settings.deck-column-width-narrow') }}</ui-radio> - <ui-radio v-model="deckColumnWidth" value="narrower">{{ $t('@._settings.deck-column-width-narrower') }}</ui-radio> - <ui-radio v-model="deckColumnWidth" value="normal">{{ $t('@._settings.deck-column-width-normal') }}</ui-radio> - <ui-radio v-model="deckColumnWidth" value="wider">{{ $t('@._settings.deck-column-width-wider') }}</ui-radio> - <ui-radio v-model="deckColumnWidth" value="wide">{{ $t('@._settings.deck-column-width-wide') }}</ui-radio> - </section> - <section> - <ui-switch v-model="games_reversi_showBoardLabels">{{ $t('@._settings.show-reversi-board-labels') }}</ui-switch> - <ui-switch v-model="games_reversi_useAvatarStones">{{ $t('@._settings.use-avatar-reversi-stones') }}</ui-switch> - </section> - </ui-card> - </template> - - <template v-if="page == null || page == 'behavior'"> - <ui-card> - <template #title><fa icon="sliders-h"/> {{ $t('@._settings.behavior') }}</template> - - <section> - <ui-switch v-model="fetchOnScroll">{{ $t('@._settings.fetch-on-scroll') }} - <template #desc>{{ $t('@._settings.fetch-on-scroll-desc') }}</template> - </ui-switch> - <ui-switch v-model="keepCw">{{ $t('@._settings.keep-cw') }} - <template #desc>{{ $t('@._settings.keep-cw-desc') }}</template> - </ui-switch> - <ui-switch v-if="$root.isMobile" v-model="disableViaMobile">{{ $t('@._settings.disable-via-mobile') }}</ui-switch> - </section> - - <section> - <header>{{ $t('@._settings.reactions') }}</header> - <ui-textarea v-model="reactions"> - {{ $t('@._settings.reactions') }}<template #desc>{{ $t('@._settings.reactions-description') }}</template> - </ui-textarea> - <ui-horizon-group> - <ui-button @click="save('reactions', reactions.trim().split('\n'))" primary><fa :icon="faSave"/> {{ $t('@._settings.save') }}</ui-button> - <ui-button @click="previewReaction()" ref="reactionsPreviewButton"><fa :icon="faEye"/> {{ $t('@._settings.preview') }}</ui-button> - </ui-horizon-group> - </section> - - <section> - <header>{{ $t('@._settings.timeline') }}</header> - <ui-switch v-model="showMyRenotes">{{ $t('@._settings.show-my-renotes') }}</ui-switch> - <ui-switch v-model="showRenotedMyNotes">{{ $t('@._settings.show-renoted-my-notes') }}</ui-switch> - <ui-switch v-model="showLocalRenotes">{{ $t('@._settings.show-local-renotes') }}</ui-switch> - </section> - - <section> - <header>{{ $t('@._settings.note-visibility') }}</header> - <ui-switch v-model="rememberNoteVisibility">{{ $t('@._settings.remember-note-visibility') }}</ui-switch> - <section> - <header>{{ $t('@._settings.default-note-visibility') }}</header> - <ui-select v-model="defaultNoteVisibility"> - <option value="public">{{ $t('@.note-visibility.public') }}</option> - <option value="home">{{ $t('@.note-visibility.home') }}</option> - <option value="followers">{{ $t('@.note-visibility.followers') }}</option> - <option value="specified">{{ $t('@.note-visibility.specified') }}</option> - <option value="local-public">{{ $t('@.note-visibility.local-public') }}</option> - <option value="local-home">{{ $t('@.note-visibility.local-home') }}</option> - <option value="local-followers">{{ $t('@.note-visibility.local-followers') }}</option> - </ui-select> - </section> - </section> - - <section> - <header>{{ $t('@._settings.sync') }}</header> - <ui-input v-if="$root.isMobile" v-model="mobileHomeProfile" :datalist="Object.keys($store.state.settings.mobileHomeProfiles)">{{ $t('@._settings.home-profile') }}</ui-input> - <ui-input v-else v-model="homeProfile" :datalist="Object.keys($store.state.settings.homeProfiles)">{{ $t('@._settings.home-profile') }}</ui-input> - <ui-input v-model="deckProfile" :datalist="Object.keys($store.state.settings.deckProfiles)">{{ $t('@._settings.deck-profile') }}</ui-input> - </section> - - <section> - <header>{{ $t('@._settings.web-search-engine') }}</header> - <ui-input v-model="webSearchEngine">{{ $t('@._settings.web-search-engine') }} - <template #desc>{{ $t('@._settings.web-search-engine-desc') }}</template> - </ui-input> - <ui-button @click="save('webSearchEngine', webSearchEngine)"><fa :icon="faSave"/> {{ $t('@._settings.save') }}</ui-button> - </section> - - <section v-if="!$root.isMobile"> - <header>{{ $t('@._settings.paste') }}</header> - <ui-input v-model="pastedFileName">{{ $t('@._settings.pasted-file-name') }} - <template v-if="pastedFileName === this.$store.state.settings.pastedFileName" #desc>{{ $t('@._settings.pasted-file-name-desc') }}</template> - <template v-else #desc>{{ pastedFileNamePreview() }}</template> - </ui-input> - <ui-button @click="save('pastedFileName', pastedFileName)"><fa :icon="faSave"/> {{ $t('@._settings.save') }}</ui-button> - - <ui-switch v-model="pasteDialog">{{ $t('@._settings.paste-dialog') }} - <template #desc>{{ $t('@._settings.paste-dialog-desc') }}</template> - </ui-switch> - </section> - - <section> - <header>{{ $t('@._settings.room') }}</header> - <ui-select v-model="roomGraphicsQuality"> - <template #label>{{ $t('@._settings._room.graphicsQuality') }}</template> - <option value="ultra">{{ $t('@._settings._room._graphicsQuality.ultra') }}</option> - <option value="high">{{ $t('@._settings._room._graphicsQuality.high') }}</option> - <option value="medium">{{ $t('@._settings._room._graphicsQuality.medium') }}</option> - <option value="low">{{ $t('@._settings._room._graphicsQuality.low') }}</option> - <option value="cheep">{{ $t('@._settings._room._graphicsQuality.cheep') }}</option> - </ui-select> - <ui-switch v-model="roomUseOrthographicCamera">{{ $t('@._settings._room.useOrthographicCamera') }}</ui-switch> - </section> - </ui-card> - - <ui-card> - <template #title><fa icon="volume-up"/> {{ $t('@._settings.sound') }}</template> - - <section> - <ui-switch v-model="enableSounds">{{ $t('@._settings.enable-sounds') }} - <template #desc>{{ $t('@._settings.enable-sounds-desc') }}</template> - </ui-switch> - <label>{{ $t('@._settings.volume') }}</label> - <input type="range" - v-model="soundVolume" - :disabled="!enableSounds" - max="1" - step="0.1" - /> - <ui-button @click="soundTest"><fa icon="volume-up"/> {{ $t('@._settings.test') }}</ui-button> - </section> - </ui-card> - - <x-language/> - <x-app-type/> - </template> - - <template v-if="page == null || page == 'notification'"> - <x-notification/> - </template> - - <template v-if="page == null || page == 'drive'"> - <x-drive/> - </template> - - <template v-if="page == null || page == 'hashtags'"> - <ui-card> - <template #title><fa icon="hashtag"/> {{ $t('@._settings.tags') }}</template> - <section> - <x-tags/> - </section> - </ui-card> - </template> - - <template v-if="page == null || page == 'muteAndBlock'"> - <x-mute-and-block/> - </template> - - <!-- - <template v-if="page == null || page == 'apps'"> - <ui-card> - <template #title><fa icon="puzzle-piece"/> {{ $t('@._settings.apps') }}</template> - <section> - <x-apps/> - </section> - </ui-card> - </template> - --> - - <template v-if="page == null || page == 'security'"> - <ui-card> - <template #title><fa icon="unlock-alt"/> {{ $t('@._settings.password') }}</template> - <section> - <x-password/> - </section> - </ui-card> - - <ui-card v-if="!$root.isMobile"> - <template #title><fa icon="mobile-alt"/> {{ $t('@.2fa') }}</template> - <section> - <x-2fa/> - </section> - </ui-card> - - <!-- - <ui-card> - <template #title><fa icon="sign-in-alt"/> {{ $t('@._settings.signin') }}</template> - <section> - <x-signins/> - </section> - </ui-card> - --> - </template> - - <template v-if="page == null || page == 'api'"> - <x-api/> - </template> - - <template v-if="page == null || page == 'other'"> - <ui-card> - <template #title><fa icon="sync-alt"/> {{ $t('@._settings.update') }}</template> - <section> - <p> - <span>{{ $t('@._settings.version') }} <i>{{ version }}</i></span> - <template v-if="latestVersion !== undefined"> - <br> - <span>{{ $t('@._settings.latest-version') }} <i>{{ latestVersion ? latestVersion : version }}</i></span> - </template> - </p> - <ui-button @click="checkForUpdate" :disabled="checkingForUpdate"> - <template v-if="checkingForUpdate">{{ $t('@._settings.update-checking') }}<mk-ellipsis/></template> - <template v-else>{{ $t('@._settings.do-update') }}</template> - </ui-button> - </section> - </ui-card> - - <ui-card> - <template #title><fa icon="cogs"/> {{ $t('@._settings.advanced-settings') }}</template> - <section> - <ui-switch v-model="debug"> - {{ $t('@._settings.debug-mode') }}<template #desc>{{ $t('@._settings.debug-mode-desc') }}</template> - </ui-switch> - </section> - </ui-card> - </template> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; -import X2fa from './2fa.vue'; -import XApps from './apps.vue'; -import XSignins from './signins.vue'; -import XTags from './tags.vue'; -import XIntegration from './integration.vue'; -import XTheme from './theme.vue'; -import XDrive from './drive.vue'; -import XMuteAndBlock from './mute-and-block.vue'; -import XPassword from './password.vue'; -import XProfile from './profile.vue'; -import XApi from './api.vue'; -import XLanguage from './language.vue'; -import XAppType from './app-type.vue'; -import XNotification from './notification.vue'; -import MkReactionPicker from '../reaction-picker.vue'; - -import { url, version } from '../../../../config'; -import checkForUpdate from '../../../scripts/check-for-update'; -import { formatTimeString } from '../../../../../../misc/format-time-string'; -import { faSave, faEye } from '@fortawesome/free-regular-svg-icons'; - -export default Vue.extend({ - i18n: i18n(), - components: { - X2fa, - XApps, - XSignins, - XTags, - XIntegration, - XTheme, - XDrive, - XMuteAndBlock, - XPassword, - XProfile, - XApi, - XLanguage, - XAppType, - XNotification, - }, - props: { - page: { - type: String, - required: false, - default: null - } - }, - data() { - return { - meta: null, - version, - reactions: this.$store.state.settings.reactions.join('\n'), - webSearchEngine: this.$store.state.settings.webSearchEngine, - pastedFileName : this.$store.state.settings.pastedFileName, - latestVersion: undefined, - checkingForUpdate: false, - faSave, faEye - }; - }, - computed: { - useOsDefaultEmojis: { - get() { return this.$store.state.device.useOsDefaultEmojis; }, - set(value) { this.$store.commit('device/set', { key: 'useOsDefaultEmojis', value }); } - }, - - reduceMotion: { - get() { return this.$store.state.device.reduceMotion; }, - set(value) { this.$store.commit('device/set', { key: 'reduceMotion', value }); } - }, - - keepCw: { - get() { return this.$store.state.settings.keepCw; }, - set(value) { this.$store.commit('settings/set', { key: 'keepCw', value }); } - }, - - navbar: { - get() { return this.$store.state.device.navbar; }, - set(value) { this.$store.commit('device/set', { key: 'navbar', value }); } - }, - - deckColumnAlign: { - get() { return this.$store.state.device.deckColumnAlign; }, - set(value) { this.$store.commit('device/set', { key: 'deckColumnAlign', value }); } - }, - - deckColumnWidth: { - get() { return this.$store.state.device.deckColumnWidth; }, - set(value) { this.$store.commit('device/set', { key: 'deckColumnWidth', value }); } - }, - - enableSounds: { - get() { return this.$store.state.device.enableSounds; }, - set(value) { this.$store.commit('device/set', { key: 'enableSounds', value }); } - }, - - soundVolume: { - get() { return this.$store.state.device.soundVolume; }, - set(value) { this.$store.commit('device/set', { key: 'soundVolume', value }); } - }, - - debug: { - get() { return this.$store.state.device.debug; }, - set(value) { this.$store.commit('device/set', { key: 'debug', value }); } - }, - - alwaysShowNsfw: { - get() { return this.$store.state.device.alwaysShowNsfw; }, - set(value) { this.$store.commit('device/set', { key: 'alwaysShowNsfw', value }); } - }, - - postStyle: { - get() { return this.$store.state.device.postStyle; }, - set(value) { this.$store.commit('device/set', { key: 'postStyle', value }); } - }, - - disableViaMobile: { - get() { return this.$store.state.settings.disableViaMobile; }, - set(value) { this.$store.dispatch('settings/set', { key: 'disableViaMobile', value }); } - }, - - useShadow: { - get() { return this.$store.state.device.useShadow; }, - set(value) { this.$store.commit('device/set', { key: 'useShadow', value }); } - }, - - roundedCorners: { - get() { return this.$store.state.device.roundedCorners; }, - set(value) { this.$store.commit('device/set', { key: 'roundedCorners', value }); } - }, - - lineWidth: { - get() { return this.$store.state.device.lineWidth; }, - set(value) { this.$store.commit('device/set', { key: 'lineWidth', value }); } - }, - - fontSize: { - get() { return this.$store.state.device.fontSize; }, - set(value) { this.$store.commit('device/set', { key: 'fontSize', value }); } - }, - - fetchOnScroll: { - get() { return this.$store.state.settings.fetchOnScroll; }, - set(value) { this.$store.dispatch('settings/set', { key: 'fetchOnScroll', value }); } - }, - - rememberNoteVisibility: { - get() { return this.$store.state.settings.rememberNoteVisibility; }, - set(value) { this.$store.dispatch('settings/set', { key: 'rememberNoteVisibility', value }); } - }, - - defaultNoteVisibility: { - get() { return this.$store.state.settings.defaultNoteVisibility; }, - set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); } - }, - - pasteDialog: { - get() { return this.$store.state.settings.pasteDialog; }, - set(value) { this.$store.dispatch('settings/set', { key: 'pasteDialog', value }); } - }, - - showReplyTarget: { - get() { return this.$store.state.settings.showReplyTarget; }, - set(value) { this.$store.dispatch('settings/set', { key: 'showReplyTarget', value }); } - }, - - showMyRenotes: { - get() { return this.$store.state.settings.showMyRenotes; }, - set(value) { this.$store.dispatch('settings/set', { key: 'showMyRenotes', value }); } - }, - - showRenotedMyNotes: { - get() { return this.$store.state.settings.showRenotedMyNotes; }, - set(value) { this.$store.dispatch('settings/set', { key: 'showRenotedMyNotes', value }); } - }, - - showLocalRenotes: { - get() { return this.$store.state.settings.showLocalRenotes; }, - set(value) { this.$store.dispatch('settings/set', { key: 'showLocalRenotes', value }); } - }, - - showPostFormOnTopOfTl: { - get() { return this.$store.state.settings.showPostFormOnTopOfTl; }, - set(value) { this.$store.dispatch('settings/set', { key: 'showPostFormOnTopOfTl', value }); } - }, - - suggestRecentHashtags: { - get() { return this.$store.state.settings.suggestRecentHashtags; }, - set(value) { this.$store.dispatch('settings/set', { key: 'suggestRecentHashtags', value }); } - }, - - showClockOnHeader: { - get() { return this.$store.state.settings.showClockOnHeader; }, - set(value) { this.$store.dispatch('settings/set', { key: 'showClockOnHeader', value }); } - }, - - circleIcons: { - get() { return this.$store.state.settings.circleIcons; }, - set(value) { - this.$store.dispatch('settings/set', { key: 'circleIcons', value }); - this.reload(); - } - }, - - contrastedAcct: { - get() { return this.$store.state.settings.contrastedAcct; }, - set(value) { - this.$store.dispatch('settings/set', { key: 'contrastedAcct', value }); - this.reload(); - } - }, - - showFullAcct: { - get() { return this.$store.state.settings.showFullAcct; }, - set(value) { - this.$store.dispatch('settings/set', { key: 'showFullAcct', value }); - this.reload(); - } - }, - - showVia: { - get() { return this.$store.state.settings.showVia; }, - set(value) { this.$store.dispatch('settings/set', { key: 'showVia', value }); } - }, - - iLikeSushi: { - get() { return this.$store.state.settings.iLikeSushi; }, - set(value) { this.$store.dispatch('settings/set', { key: 'iLikeSushi', value }); } - }, - - roomUseOrthographicCamera: { - get() { return this.$store.state.device.roomUseOrthographicCamera; }, - set(value) { this.$store.commit('device/set', { key: 'roomUseOrthographicCamera', value }); } - }, - - roomGraphicsQuality: { - get() { return this.$store.state.device.roomGraphicsQuality; }, - set(value) { this.$store.commit('device/set', { key: 'roomGraphicsQuality', value }); } - }, - - games_reversi_showBoardLabels: { - get() { return this.$store.state.settings.gamesReversiShowBoardLabels; }, - set(value) { this.$store.dispatch('settings/set', { key: 'gamesReversiShowBoardLabels', value }); } - }, - - games_reversi_useAvatarStones: { - get() { return this.$store.state.settings.gamesReversiUseAvatarStones; }, - set(value) { this.$store.dispatch('settings/set', { key: 'gamesReversiUseAvatarStones', value }); } - }, - - disableAnimatedMfm: { - get() { return this.$store.state.settings.disableAnimatedMfm; }, - set(value) { this.$store.dispatch('settings/set', { key: 'disableAnimatedMfm', value }); } - }, - - disableShowingAnimatedImages: { - get() { return this.$store.state.device.disableShowingAnimatedImages; }, - set(value) { this.$store.commit('device/set', { key: 'disableShowingAnimatedImages', value }); } - }, - - remainDeletedNote: { - get() { return this.$store.state.settings.remainDeletedNote; }, - set(value) { this.$store.dispatch('settings/set', { key: 'remainDeletedNote', value }); } - }, - - mobileNotificationPosition: { - get() { return this.$store.state.device.mobileNotificationPosition; }, - set(value) { this.$store.commit('device/set', { key: 'mobileNotificationPosition', value }); } - }, - - enableMobileQuickNotificationView: { - get() { return this.$store.state.device.enableMobileQuickNotificationView; }, - set(value) { this.$store.commit('device/set', { key: 'enableMobileQuickNotificationView', value }); } - }, - - homeProfile: { - get() { return this.$store.state.device.homeProfile; }, - set(value) { this.$store.commit('device/set', { key: 'homeProfile', value }); } - }, - - mobileHomeProfile: { - get() { return this.$store.state.device.mobileHomeProfile; }, - set(value) { this.$store.commit('device/set', { key: 'mobileHomeProfile', value }); } - }, - - deckProfile: { - get() { return this.$store.state.device.deckProfile; }, - set(value) { this.$store.commit('device/set', { key: 'deckProfile', value }); } - }, - }, - created() { - this.$root.getMeta().then(meta => { - this.meta = meta; - }); - }, - methods: { - reload() { - this.$root.dialog({ - type: 'warning', - text: this.$t('@.reload-to-apply-the-setting'), - showCancelButton: true - }).then(({ canceled }) => { - if (!canceled) { - location.reload(); - } - }); - }, - save(key, value) { - this.$store.dispatch('settings/set', { - key, - value - }).then(() => { - this.$root.dialog({ - type: 'success', - text: this.$t('@._settings.saved') - }) - }); - }, - customizeHome() { - location.href = '/?customize'; - }, - updateWallpaper() { - this.$chooseDriveFile({ - multiple: false - }).then(file => { - this.$store.dispatch('settings/set', { key: 'wallpaper', value: file.url }); - }); - }, - deleteWallpaper() { - this.$store.dispatch('settings/set', { key: 'wallpaper', value: null }); - }, - checkForUpdate() { - this.checkingForUpdate = true; - checkForUpdate(this.$root, true, true).then(newer => { - this.checkingForUpdate = false; - this.latestVersion = newer; - if (newer == null) { - this.$root.dialog({ - title: this.$t('@._settings.no-updates'), - text: this.$t('@._settings.no-updates-desc') - }); - } else { - this.$root.dialog({ - title: this.$t('@._settings.update-available'), - text: this.$t('@._settings.update-available-desc') - }); - } - }); - }, - soundTest() { - const sound = new Audio(`${url}/assets/message.mp3`); - sound.volume = this.$store.state.device.soundVolume; - sound.play(); - }, - pastedFileNamePreview() { - return `${formatTimeString(new Date(), this.pastedFileName).replace(/{{number}}/g, `1`)}.png` - }, - previewReaction() { - const picker = this.$root.new(MkReactionPicker, { - source: this.$refs.reactionsPreviewButton.$el, - reactions: this.reactions.trim().split('\n'), - showFocus: false, - }); - picker.$once('chosen', reaction => { - picker.close(); - }); - } - } -}); -</script> diff --git a/src/client/app/common/views/components/settings/signins.vue b/src/client/app/common/views/components/settings/signins.vue deleted file mode 100644 index 048fa2fc5b..0000000000 --- a/src/client/app/common/views/components/settings/signins.vue +++ /dev/null @@ -1,98 +0,0 @@ -<template> -<div class="root"> -<div class="signins" v-if="signins.length != 0"> - <div v-for="signin in signins"> - <header @click="signin._show = !signin._show"> - <template v-if="signin.success"><fa icon="check"/></template> - <template v-else><fa icon="times"/></template> - <span class="ip">{{ signin.ip }}</span> - <mk-time :time="signin.createdAt"/> - </header> - <div class="headers" v-show="signin._show"> - <!-- TODO --> - </div> - </div> -</div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - data() { - return { - fetching: true, - signins: [], - connection: null - }; - }, - - mounted() { - this.$root.api('i/signin_history').then(signins => { - this.signins = signins; - this.fetching = false; - }); - - this.connection = this.$root.stream.useSharedConnection('main'); - - this.connection.on('signin', this.onSignin); - }, - - beforeDestroy() { - this.connection.dispose(); - }, - - methods: { - onSignin(signin) { - this.signins.unshift(signin); - } - } -}); -</script> - -<style lang="stylus" scoped> -.root - > .signins - > div - border-bottom solid 1px #eee - - > header - display flex - padding 8px 0 - line-height 32px - cursor pointer - - > [data-icon] - margin-right 8px - text-align left - - &.check - color #0fda82 - - &.times - color #ff3100 - - > .ip - display inline-block - text-align left - padding 8px - line-height 16px - font-family monospace - font-size 14px - color #444 - background #f8f8f8 - border-radius 4px - - > .mk-time - margin-left auto - text-align right - color #777 - - > .headers - overflow auto - margin 0 0 16px 0 - max-height 100px - white-space pre-wrap - word-break break-all - -</style> diff --git a/src/client/app/common/views/components/settings/tags.vue b/src/client/app/common/views/components/settings/tags.vue deleted file mode 100644 index 2e17f35e3e..0000000000 --- a/src/client/app/common/views/components/settings/tags.vue +++ /dev/null @@ -1,67 +0,0 @@ -<template> -<div class="vfcitkilproprqtbnpoertpsziierwzi"> - <div v-for="timeline in timelines" class="timeline" :key="timeline.id"> - <ui-input v-model="timeline.title" @change="save"> - <span>{{ $t('title') }}</span> - </ui-input> - <ui-textarea :value="timeline.query ? timeline.query.map(tags => tags.join(' ')).join('\n') : ''" :pre="true" @input="onQueryChange(timeline, $event)"> - <span>{{ $t('query') }}</span> - </ui-textarea> - </div> - <ui-button class="add" @click="add">{{ $t('add') }}</ui-button> - <ui-button class="save" @click="save">{{ $t('save') }}</ui-button> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; -import { v4 as uuid } from 'uuid'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/settings.tags.vue'), - data() { - return { - timelines: this.$store.state.settings.tagTimelines - }; - }, - - methods: { - add() { - this.timelines.push({ - id: uuid(), - title: '', - query: '' - }); - }, - - save() { - const timelines = this.timelines - .filter(timeline => timeline.title) - .map(timeline => { - if (!(timeline.query && timeline.query[0] && timeline.query[0][0])) { - timeline.query = timeline.title.split('\n').map(tags => tags.split(' ')); - } - return timeline; - }); - - this.$store.dispatch('settings/set', { key: 'tagTimelines', value: timelines }); - }, - - onQueryChange(timeline, value) { - timeline.query = value.split('\n').map(tags => tags.split(' ')); - } - } -}); -</script> - -<style lang="stylus" scoped> -.vfcitkilproprqtbnpoertpsziierwzi - > .timeline - padding-bottom 16px - border-bottom solid 1px rgba(#000, 0.1) - - > .add - margin-top 16px - -</style> diff --git a/src/client/app/common/views/components/settings/theme.vue b/src/client/app/common/views/components/settings/theme.vue deleted file mode 100644 index d916a57508..0000000000 --- a/src/client/app/common/views/components/settings/theme.vue +++ /dev/null @@ -1,558 +0,0 @@ -<template> -<ui-card> - <template #title><fa icon="palette"/> {{ $t('theme') }}</template> - <section class="nicnklzforebnpfgasiypmpdaaglujqm fit-top"> - <div class="dark"> - <div class="toggleWrapper"> - <input type="checkbox" class="dn" id="dn" v-model="darkmode"/> - <label for="dn" class="toggle"> - <span class="toggle__handler"> - <span class="crater crater--1"></span> - <span class="crater crater--2"></span> - <span class="crater crater--3"></span> - </span> - <span class="star star--1"></span> - <span class="star star--2"></span> - <span class="star star--3"></span> - <span class="star star--4"></span> - <span class="star star--5"></span> - <span class="star star--6"></span> - </label> - </div> - </div> - - <label> - <ui-select v-model="light" :placeholder="$t('light-theme')"> - <template #label><fa :icon="faSun"/> {{ $t('light-theme') }}</template> - <optgroup :label="$t('light-themes')"> - <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="$t('dark-themes')"> - <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - </ui-select> - </label> - - <label> - <ui-select v-model="dark" :placeholder="$t('dark-theme')"> - <template #label><fa :icon="faMoon"/> {{ $t('dark-theme') }}</template> - <optgroup :label="$t('dark-themes')"> - <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="$t('light-themes')"> - <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - </ui-select> - </label> - - <a href="https://assets.msky.cafe/theme/list" rel="noopener" target="_blank">{{ $t('find-more-theme') }}</a> - - <details class="creator"> - <summary><fa icon="palette"/> {{ $t('create-a-theme') }}</summary> - <div> - <span>{{ $t('base-theme') }}:</span> - <ui-radio v-model="myThemeBase" value="light">{{ $t('base-theme-light') }}</ui-radio> - <ui-radio v-model="myThemeBase" value="dark">{{ $t('base-theme-dark') }}</ui-radio> - </div> - <div> - <ui-input v-model="myThemeName"> - <span>{{ $t('theme-name') }}</span> - </ui-input> - <ui-textarea v-model="myThemeDesc"> - <span>{{ $t('desc') }}</span> - </ui-textarea> - </div> - <div> - <div style="padding-bottom:8px;">{{ $t('primary-color') }}:</div> - <color-picker v-model="myThemePrimary"/> - </div> - <div> - <div style="padding-bottom:8px;">{{ $t('secondary-color') }}:</div> - <color-picker v-model="myThemeSecondary"/> - </div> - <div> - <div style="padding-bottom:8px;">{{ $t('text-color') }}:</div> - <color-picker v-model="myThemeText"/> - </div> - <ui-button @click="preview()"><fa icon="eye"/> {{ $t('preview-created-theme') }}</ui-button> - <ui-button primary @click="gen()"><fa :icon="['far', 'save']"/> {{ $t('save-created-theme') }}</ui-button> - </details> - - <details> - <summary><fa icon="download"/> {{ $t('install-a-theme') }}</summary> - <ui-button @click="import_()"><fa icon="file-import"/> {{ $t('import') }}</ui-button> - <input ref="file" type="file" accept=".misskeytheme" style="display:none;" @change="onUpdateImportFile"/> - <p>{{ $t('import-by-code') }}:</p> - <ui-textarea v-model="installThemeCode"> - <span>{{ $t('theme-code') }}</span> - </ui-textarea> - <ui-button @click="() => install(this.installThemeCode)"><fa icon="check"/> {{ $t('install') }}</ui-button> - </details> - - <details> - <summary><fa icon="folder-open"/> {{ $t('manage-themes') }}</summary> - <ui-select v-model="selectedThemeId" :placeholder="$t('select-theme')"> - <optgroup :label="$t('builtin-themes')"> - <option v-for="x in builtinThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="$t('my-themes')"> - <option v-for="x in installedThemes.filter(t => t.author == this.$store.state.i.username)" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="$t('installed-themes')"> - <option v-for="x in installedThemes.filter(t => t.author != this.$store.state.i.username)" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - </ui-select> - <template v-if="selectedTheme"> - <ui-input readonly :value="selectedTheme.author"> - <span>{{ $t('author') }}</span> - </ui-input> - <ui-textarea v-if="selectedTheme.desc" readonly :value="selectedTheme.desc"> - <span>{{ $t('desc') }}</span> - </ui-textarea> - <ui-textarea readonly tall :value="selectedThemeCode"> - <span>{{ $t('theme-code') }}</span> - </ui-textarea> - <ui-button @click="export_()" link :download="`${selectedTheme.name}.misskeytheme`" ref="export"><fa icon="box"/> {{ $t('export') }}</ui-button> - <ui-button @click="uninstall()" v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><fa :icon="['far', 'trash-alt']"/> {{ $t('uninstall') }}</ui-button> - </template> - </details> - </section> -</ui-card> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; -import { lightTheme, darkTheme, builtinThemes, applyTheme, Theme } from '../../../../theme'; -import { Chrome } from 'vue-color'; -import { v4 as uuid } from 'uuid'; -import * as tinycolor from 'tinycolor2'; -import * as JSON5 from 'json5'; -import { faMoon, faSun } from '@fortawesome/free-regular-svg-icons'; - -export default Vue.extend({ - i18n: i18n('common/views/components/theme.vue'), - components: { - ColorPicker: Chrome - }, - - data() { - return { - builtinThemes: builtinThemes, - installThemeCode: null, - selectedThemeId: null, - myThemeBase: 'light', - myThemeName: '', - myThemeDesc: '', - myThemePrimary: lightTheme.vars.primary, - myThemeSecondary: lightTheme.vars.secondary, - myThemeText: lightTheme.vars.text, - faMoon, faSun - }; - }, - - computed: { - themes(): Theme[] { - return builtinThemes.concat(this.$store.state.device.themes); - }, - - darkThemes(): Theme[] { - return this.themes.filter(t => t.base == 'dark' || t.kind == 'dark'); - }, - - lightThemes(): Theme[] { - return this.themes.filter(t => t.base == 'light' || t.kind == 'light'); - }, - - installedThemes(): Theme[] { - return this.$store.state.device.themes; - }, - - light: { - get() { return this.$store.state.device.lightTheme; }, - set(value) { this.$store.commit('device/set', { key: 'lightTheme', value }); } - }, - - dark: { - get() { return this.$store.state.device.darkTheme; }, - set(value) { this.$store.commit('device/set', { key: 'darkTheme', value }); } - }, - - selectedTheme() { - if (this.selectedThemeId == null) return null; - return this.themes.find(x => x.id == this.selectedThemeId); - }, - - selectedThemeCode() { - if (this.selectedTheme == null) return null; - return JSON5.stringify(this.selectedTheme, null, '\t'); - }, - - myTheme(): any { - return { - name: this.myThemeName, - author: this.$store.state.i.username, - desc: this.myThemeDesc, - base: this.myThemeBase, - vars: { - primary: tinycolor(typeof this.myThemePrimary == 'string' ? this.myThemePrimary : this.myThemePrimary.rgba).toRgbString(), - secondary: tinycolor(typeof this.myThemeSecondary == 'string' ? this.myThemeSecondary : this.myThemeSecondary.rgba).toRgbString(), - text: tinycolor(typeof this.myThemeText == 'string' ? this.myThemeText : this.myThemeText.rgba).toRgbString() - } - }; - }, - - darkmode: { - get() { return this.$store.state.device.darkmode; }, - set(value) { this.$store.commit('device/set', { key: 'darkmode', value }); } - }, - }, - - watch: { - myThemeBase(v) { - const theme = v == 'light' ? lightTheme : darkTheme; - this.myThemePrimary = theme.vars.primary; - this.myThemeSecondary = theme.vars.secondary; - this.myThemeText = theme.vars.text; - } - }, - - methods: { - install(code) { - let theme; - - try { - theme = JSON5.parse(code); - } catch (e) { - this.$root.dialog({ - type: 'error', - text: this.$t('invalid-theme') - }); - return; - } - - if (theme.id == null) { - this.$root.dialog({ - type: 'error', - text: this.$t('invalid-theme') - }); - return; - } - - if (this.$store.state.device.themes.some(t => t.id == theme.id)) { - this.$root.dialog({ - type: 'info', - text: this.$t('already-installed') - }); - return; - } - - const themes = this.$store.state.device.themes.concat(theme); - this.$store.commit('device/set', { - key: 'themes', value: themes - }); - - this.$root.dialog({ - type: 'success', - text: this.$t('installed').replace('{}', theme.name) - }); - }, - - uninstall() { - const theme = this.selectedTheme; - const themes = this.$store.state.device.themes.filter(t => t.id != theme.id); - this.$store.commit('device/set', { - key: 'themes', value: themes - }); - - this.$root.dialog({ - type: 'info', - text: this.$t('uninstalled').replace('{}', theme.name) - }); - }, - - import_() { - (this.$refs.file as any).click(); - }, - - export_() { - const blob = new Blob([this.selectedThemeCode], { - type: 'application/json5' - }); - this.$refs.export.$el.href = window.URL.createObjectURL(blob); - }, - - onUpdateImportFile() { - const f = (this.$refs.file as any).files[0]; - - const reader = new FileReader(); - - reader.onload = e => { - this.install(e.target.result); - }; - - reader.readAsText(f); - }, - - preview() { - applyTheme(this.myTheme, false); - }, - - gen() { - const theme = this.myTheme; - - if (theme.name == null || theme.name.trim() == '') { - this.$root.dialog({ - type: 'warning', - text: this.$t('theme-name-required') - }); - return; - } - - theme.id = uuid(); - - const themes = this.$store.state.device.themes.concat(theme); - this.$store.commit('device/set', { - key: 'themes', value: themes - }); - - this.$root.dialog({ - type: 'success', - text: this.$t('saved') - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.nicnklzforebnpfgasiypmpdaaglujqm - > .dark - margin-top 48px - margin-bottom 110px - - .toggleWrapper { - position: absolute; - top: 50%; - left: 50%; - overflow: hidden; - padding: 0 200px; - transform: translate3d(-50%, -50%, 0); - - input { - position: absolute; - left: -99em; - } - } - - .toggle { - cursor: pointer; - display: inline-block; - position: relative; - width: 90px; - height: 50px; - background-color: #83D8FF; - border-radius: 90px - 6; - transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; - - &:before { - content: 'Light'; - position: absolute; - left: -60px; - top: 15px; - font-size: 18px; - color: var(--primary); - } - - &:after { - content: 'Dark'; - position: absolute; - right: -58px; - top: 15px; - font-size: 18px; - color: var(--text); - } - } - - .toggle__handler { - display: inline-block; - position: relative; - z-index: 1; - top: 3px; - left: 3px; - width: 50px - 6; - height: 50px - 6; - background-color: #FFCF96; - border-radius: 50px; - box-shadow: 0 2px 6px rgba(0,0,0,.3); - transition: all 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55) !important; - transform: rotate(-45deg); - - .crater { - position: absolute; - background-color: #E8CDA5; - opacity: 0; - transition: opacity 200ms ease-in-out !important; - border-radius: 100%; - } - - .crater--1 { - top: 18px; - left: 10px; - width: 4px; - height: 4px; - } - - .crater--2 { - top: 28px; - left: 22px; - width: 6px; - height: 6px; - } - - .crater--3 { - top: 10px; - left: 25px; - width: 8px; - height: 8px; - } - } - - .star { - position: absolute; - background-color: #ffffff; - transition: all 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; - border-radius: 50%; - } - - .star--1 { - top: 10px; - left: 35px; - z-index: 0; - width: 30px; - height: 3px; - } - - .star--2 { - top: 18px; - left: 28px; - z-index: 1; - width: 30px; - height: 3px; - } - - .star--3 { - top: 27px; - left: 40px; - z-index: 0; - width: 30px; - height: 3px; - } - - .star--4, - .star--5, - .star--6 { - opacity: 0; - transition: all 300ms 0 cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; - } - - .star--4 { - top: 16px; - left: 11px; - z-index: 0; - width: 2px; - height: 2px; - transform: translate3d(3px,0,0); - } - - .star--5 { - top: 32px; - left: 17px; - z-index: 0; - width: 3px; - height: 3px; - transform: translate3d(3px,0,0); - } - - .star--6 { - top: 36px; - left: 28px; - z-index: 0; - width: 2px; - height: 2px; - transform: translate3d(3px,0,0); - } - - input:checked { - + .toggle { - background-color: #749DD6; - - &:before { - color: var(--text); - } - - &:after { - color: var(--primary); - } - - .toggle__handler { - background-color: #FFE5B5; - transform: translate3d(40px, 0, 0) rotate(0); - - .crater { opacity: 1; } - } - - .star--1 { - width: 2px; - height: 2px; - } - - .star--2 { - width: 4px; - height: 4px; - transform: translate3d(-5px, 0, 0); - } - - .star--3 { - width: 2px; - height: 2px; - transform: translate3d(-7px, 0, 0); - } - - .star--4, - .star--5, - .star--6 { - opacity: 1; - transform: translate3d(0,0,0); - } - .star--4 { - transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; - } - .star--5 { - transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; - } - .star--6 { - transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; - } - } - } - - > a - display block - margin-top -16px - margin-bottom 16px - - > details - border-top solid var(--lineWidth) var(--faceDivider) - - > summary - padding 16px 0 - - > *:last-child - margin-bottom 16px - - > .creator - > div - padding 16px 0 - border-bottom solid var(--lineWidth) var(--faceDivider) -</style> diff --git a/src/client/app/common/views/components/stream-indicator.vue b/src/client/app/common/views/components/stream-indicator.vue deleted file mode 100644 index 8ab1cfcfeb..0000000000 --- a/src/client/app/common/views/components/stream-indicator.vue +++ /dev/null @@ -1,88 +0,0 @@ -<template> -<div class="mk-stream-indicator"> - <p v-if="stream.state == 'initializing'"> - <fa icon="spinner" pulse/> - <span>{{ $t('connecting') }}<mk-ellipsis/></span> - </p> - <p v-if="stream.state == 'reconnecting'"> - <fa icon="spinner" pulse/> - <span>{{ $t('reconnecting') }}<mk-ellipsis/></span> - </p> - <p v-if="stream.state == 'connected'"> - <fa icon="check"/> - <span>{{ $t('connected') }}</span> - </p> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import anime from 'animejs'; - -export default Vue.extend({ - i18n: i18n('common/views/components/stream-indicator.vue'), - computed: { - stream() { - return this.$root.stream; - } - }, - created() { - this.$root.stream.on('_connected_', this.onConnected); - this.$root.stream.on('_disconnected_', this.onDisconnected); - - this.$nextTick(() => { - if (this.stream.state == 'connected') { - this.$el.style.opacity = '0'; - } - }); - }, - beforeDestroy() { - this.$root.stream.off('_connected_', this.onConnected); - this.$root.stream.off('_disconnected_', this.onDisconnected); - }, - methods: { - onConnected() { - setTimeout(() => { - anime({ - targets: this.$el, - opacity: 0, - easing: 'linear', - duration: 200 - }); - }, 1000); - }, - onDisconnected() { - anime({ - targets: this.$el, - opacity: 1, - easing: 'linear', - duration: 100 - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-stream-indicator - pointer-events none - position fixed - z-index 16384 - bottom 8px - right 8px - margin 0 - padding 6px 12px - font-size 0.9em - color #fff - background rgba(#000, 0.8) - border-radius 4px - - > p - display block - margin 0 - - > [data-icon] - margin-right 0.25em - -</style> diff --git a/src/client/app/common/views/components/tag-cloud.vue b/src/client/app/common/views/components/tag-cloud.vue deleted file mode 100644 index 3fa5e3b9d4..0000000000 --- a/src/client/app/common/views/components/tag-cloud.vue +++ /dev/null @@ -1,86 +0,0 @@ -<template> -<div class="jtivnzhfwquxpsfidertopbmwmchmnmo"> - <p class="fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p> - <p class="empty" v-else-if="tags.length == 0"><fa icon="exclamation-circle"/>{{ $t('empty') }}</p> - <div v-else> - <vue-word-cloud - :words="tags.slice(0, 20).map(x => [x.tag, x.count])" - :color="color" - :spacing="1"> - <template slot-scope="{word, text, weight}"> - <div style="cursor: pointer;" :title="weight"> - {{ text }} - </div> - </template> - </vue-word-cloud> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import * as VueWordCloud from 'vuewordcloud'; - -export default Vue.extend({ - i18n: i18n('common/views/components/tag-cloud.vue'), - components: { - [VueWordCloud.name]: VueWordCloud - }, - data() { - return { - tags: [], - fetching: true, - clock: null - }; - }, - mounted() { - this.fetch(); - this.clock = setInterval(this.fetch, 1000 * 60); - }, - beforeDestroy() { - clearInterval(this.clock); - }, - methods: { - fetch() { - this.$root.api('hashtags/trend').then(tags => { - this.tags = tags; - this.fetching = false; - }); - }, - color([, weight]) { - const peak = Math.max.apply(null, this.tags.map(x => x.count)); - const w = weight / peak; - - if (w > 0.9) { - return this.$store.state.device.darkmode ? '#ff4e69' : '#ff4e69'; - } else if (w > 0.5) { - return this.$store.state.device.darkmode ? '#3bc4c7' : '#3bc4c7'; - } else { - return this.$store.state.device.darkmode ? '#fff' : '#555'; - } - } - } -}); -</script> - -<style lang="stylus" scoped> -.jtivnzhfwquxpsfidertopbmwmchmnmo - height 100% - width 100% - - > .fetching - > .empty - margin 0 - padding 16px - text-align center - color var(--text) - - > [data-icon] - margin-right 4px - - > div - height 100% - width 100% - -</style> diff --git a/src/client/app/common/views/components/trends.vue b/src/client/app/common/views/components/trends.vue deleted file mode 100644 index 536d55247c..0000000000 --- a/src/client/app/common/views/components/trends.vue +++ /dev/null @@ -1,100 +0,0 @@ -<template> -<div class="csqvmxybqbycalfhkxvyfrgbrdalkaoc"> - <p class="fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p> - <p class="empty" v-else-if="stats.length == 0"><fa icon="exclamation-circle"/>{{ $t('empty') }}</p> - <!-- トランジションを有効にするとなぜかメモリリークする --> - <transition-group v-else tag="div" name="chart"> - <div v-for="stat in stats" :key="stat.tag"> - <div class="tag"> - <router-link :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</router-link> - <p>{{ $t('count').replace('{}', stat.usersCount) }}</p> - </div> - <x-chart class="chart" :src="stat.chart"/> - </div> - </transition-group> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XChart from './trends.chart.vue'; - -export default Vue.extend({ - i18n: i18n('common/views/components/trends.vue'), - components: { - XChart - }, - data() { - return { - stats: [], - fetching: true, - clock: null - }; - }, - mounted() { - this.fetch(); - this.clock = setInterval(this.fetch, 1000 * 60); - }, - beforeDestroy() { - clearInterval(this.clock); - }, - methods: { - fetch() { - this.$root.api('hashtags/trend').then(stats => { - this.stats = stats; - this.fetching = false; - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.csqvmxybqbycalfhkxvyfrgbrdalkaoc - > .fetching - > .empty - margin 0 - padding 16px - text-align center - color var(--text) - opacity 0.7 - - > [data-icon] - margin-right 4px - - > div - .chart-move - transition transform 1s ease - - > div - display flex - align-items center - padding 14px 16px - - &:not(:last-child) - border-bottom solid 1px var(--faceDivider) - - > .tag - flex 1 - overflow hidden - font-size 14px - color var(--text) - - > a - display block - width 100% - white-space nowrap - overflow hidden - text-overflow ellipsis - color inherit - - > p - margin 0 - font-size 75% - opacity 0.7 - - > .chart - height 30px - -</style> diff --git a/src/client/app/common/views/components/ui/button.vue b/src/client/app/common/views/components/ui/button.vue deleted file mode 100644 index 59a5c858a7..0000000000 --- a/src/client/app/common/views/components/ui/button.vue +++ /dev/null @@ -1,224 +0,0 @@ -<template> -<component class="dmtdnykelhudezerjlfpbhgovrgnqqgr" - :is="link ? 'a' : 'button'" - :class="{ inline, primary, wait, round: $store.state.device.roundedCorners }" - :type="type" - @click="$emit('click')" - @mousedown="onMousedown" -> - <div ref="ripples" class="ripples"></div> - <div class="content"> - <slot></slot> - </div> -</component> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - inject: { - horizonGrouped: { - default: false - } - }, - props: { - type: { - type: String, - required: false - }, - primary: { - type: Boolean, - required: false, - default: false - }, - inline: { - type: Boolean, - required: false, - default(): boolean { - return this.horizonGrouped; - } - }, - link: { - type: Boolean, - required: false, - default: false - }, - autofocus: { - type: Boolean, - required: false, - default: false - }, - wait: { - type: Boolean, - required: false, - default: false - }, - }, - mounted() { - if (this.autofocus) { - this.$nextTick(() => { - this.$el.focus(); - }); - } - }, - methods: { - onMousedown(e: MouseEvent) { - function distance(p, q) { - return Math.hypot(p.x - q.x, p.y - q.y); - } - - function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY) { - const origin = {x: circleCenterX, y: circleCenterY}; - const dist1 = distance({x: 0, y: 0}, origin); - const dist2 = distance({x: boxW, y: 0}, origin); - const dist3 = distance({x: 0, y: boxH}, origin); - const dist4 = distance({x: boxW, y: boxH }, origin); - return Math.max(dist1, dist2, dist3, dist4) * 2; - } - - const rect = e.target.getBoundingClientRect(); - - const ripple = document.createElement('div'); - ripple.style.top = (e.clientY - rect.top - 1).toString() + 'px'; - ripple.style.left = (e.clientX - rect.left - 1).toString() + 'px'; - - this.$refs.ripples.appendChild(ripple); - - const circleCenterX = e.clientX - rect.left; - const circleCenterY = e.clientY - rect.top; - - const scale = calcCircleScale(e.target.clientWidth, e.target.clientHeight, circleCenterX, circleCenterY); - - setTimeout(() => { - ripple.style.transform = 'scale(' + (scale / 2) + ')'; - }, 1); - setTimeout(() => { - ripple.style.transition = 'all 1s ease'; - ripple.style.opacity = '0'; - }, 1000); - setTimeout(() => { - if (this.$refs.ripples) this.$refs.ripples.removeChild(ripple); - }, 2000); - } - } -}); -</script> - -<style lang="stylus" scoped> -.dmtdnykelhudezerjlfpbhgovrgnqqgr - display block - width 100% - margin 0 - padding 8px 10px - text-align center - font-weight normal - font-size 14px - line-height 24px - border none - outline none - box-shadow none - text-decoration none - user-select none - color var(--text) - background var(--buttonBg) - - &.round - border-radius 6px - - &:not(:disabled):hover - background var(--buttonHoverBg) - - &:not(:disabled):active - background var(--buttonActiveBg) - - &.primary - color var(--primaryForeground) - background var(--primary) - - &:not(:disabled):hover - background var(--primaryLighten5) - - &:not(:disabled):active - background var(--primaryDarken5) - - * - pointer-events none - user-select none - - &:disabled - opacity 0.7 - - &:focus - &:after - content "" - pointer-events none - position absolute - top -5px - right -5px - bottom -5px - left -5px - border 2px solid var(--primaryAlpha03) - - &.round:focus:after - border-radius 10px - - &:not(.inline) + .dmtdnykelhudezerjlfpbhgovrgnqqgr - margin-top 16px - - &.inline - display inline-block - width auto - min-width 100px - - &.primary - font-weight bold - - &.wait - background linear-gradient( - 45deg, - var(--primaryDarken10) 25%, - var(--primary) 25%, - var(--primary) 50%, - var(--primaryDarken10) 50%, - var(--primaryDarken10) 75%, - var(--primary) 75%, - var(--primary) - ) - background-size 32px 32px - animation stripe-bg 1.5s linear infinite - opacity 0.7 - cursor wait - - @keyframes stripe-bg - from {background-position: 0 0;} - to {background-position: -64px 32px;} - - > .ripples - position absolute - z-index 0 - top 0 - left 0 - width 100% - height 100% - 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) - - &.round > .ripples - border-radius 6px - - &.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 deleted file mode 100644 index a83013f5d0..0000000000 --- a/src/client/app/common/views/components/ui/card.vue +++ /dev/null @@ -1,69 +0,0 @@ -<template> -<div class="ui-card" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> - <header> - <slot name="title"></slot> - </header> - - <slot></slot> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - provide() { - return { - isCardChild: true - }; - } -}); -</script> - -<style lang="stylus" scoped> -.ui-card - margin 16px - max-width 850px - color var(--faceText) - background var(--face) - - &.round - border-radius 6px - - &.shadow - box-shadow 0 3px 1px -2px rgba(#000, 0.2), 0 2px 2px 0 rgba(#000, 0.14), 0 1px 5px 0 rgba(#000, 0.12) - - > header - padding 16px - font-weight bold - font-size 20px - color var(--faceText) - - @media (min-width 500px) - padding 24px 32px - - > section - padding 20px 16px - border-top solid var(--lineWidth) var(--faceDivider) - - @media (min-width 500px) - padding 32px - - &.fit-top - padding-top 0 - - &.fit-bottom - padding-bottom 0 - - > header - margin-bottom 16px - font-weight bold - color var(--faceText) - - > section - margin 16px 0 - - > header - font-weight bold - color var(--text) - -</style> diff --git a/src/client/app/common/views/components/ui/form.vue b/src/client/app/common/views/components/ui/form.vue deleted file mode 100644 index 5c5bbd7256..0000000000 --- a/src/client/app/common/views/components/ui/form.vue +++ /dev/null @@ -1,30 +0,0 @@ -<template> -<div class="ui-form"> - <fieldset :disabled="disabled"> - <slot></slot> - </fieldset> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - props: { - disabled: { - type: Boolean, - required: false - } - } -}); -</script> - -<style lang="stylus" scoped> - - -.ui-form - > fieldset - margin 0 - padding 0 - border none - -</style> diff --git a/src/client/app/common/views/components/ui/form/button.vue b/src/client/app/common/views/components/ui/form/button.vue deleted file mode 100644 index 3fd7b47629..0000000000 --- a/src/client/app/common/views/components/ui/form/button.vue +++ /dev/null @@ -1,81 +0,0 @@ -<template> -<div class="nvemkhtwcnnpkdrwfcbzuwhfulejhmzg" :class="{ round, primary }"> - <button @click="$emit('click')"> - <slot></slot> - </button> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - props: { - round: { - type: Boolean, - required: false, - default: false - }, - primary: { - type: Boolean, - required: false, - default: false - } - } -}); -</script> - -<style lang="stylus" scoped> -.nvemkhtwcnnpkdrwfcbzuwhfulejhmzg - display inline-block - - & + .nvemkhtwcnnpkdrwfcbzuwhfulejhmzg - margin-left 12px - - > button - display inline-block - margin 0 - padding 12px 20px - font-size 14px - border 1px solid var(--formButtonBorder) - border-radius 4px - outline none - box-shadow none - color var(--text) - transition 0.1s - - * - pointer-events none - - &:hover - &:focus - color var(--primary) - background var(--formButtonHoverBg) - border-color var(--formButtonHoverBorder) - - &:active - color var(--primaryDarken20) - background var(--formButtonActiveBg) - border-color var(--primary) - transition all 0s - - &.primary - > button - border 1px solid var(--primary) - background var(--primary) - color var(--primaryForeground) - - &:hover - &:focus - background var(--primaryLighten20) - border-color var(--primaryLighten20) - - &:active - background var(--primaryDarken20) - border-color var(--primaryDarken20) - transition all 0s - - &.round - > button - border-radius 64px - -</style> diff --git a/src/client/app/common/views/components/ui/form/radio.vue b/src/client/app/common/views/components/ui/form/radio.vue deleted file mode 100644 index 396b2997e5..0000000000 --- a/src/client/app/common/views/components/ui/form/radio.vue +++ /dev/null @@ -1,118 +0,0 @@ -<template> -<div - class="uywduthvrdnlpsvsjkqigicixgyfctto" - :class="{ disabled, checked }" - :aria-checked="checked" - :aria-disabled="disabled" - @click="toggle" -> - <input type="radio" - :disabled="disabled" - > - <span class="button"> - <span></span> - </span> - <span class="label"><slot></slot></span> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - model: { - prop: 'model', - event: 'change' - }, - props: { - model: { - required: false - }, - value: { - required: false - }, - disabled: { - type: Boolean, - default: false - } - }, - computed: { - checked(): boolean { - return this.model === this.value; - } - }, - methods: { - toggle() { - this.$emit('change', this.value); - } - } -}); -</script> - -<style lang="stylus" scoped> -.uywduthvrdnlpsvsjkqigicixgyfctto - display inline-flex - margin 0 16px 0 0 - cursor pointer - transition all 0.3s - - > * - user-select none - - &:hover - > .button - border solid 2px var(--inputLabel) - - &.disabled - opacity 0.6 - cursor not-allowed - - &.checked - > .button - border-color var(--primary) - - &:after - background-color var(--primary) - transform scale(1) - opacity 1 - - > .label - color var(--primary) - - > input - position absolute - width 0 - height 0 - opacity 0 - margin 0 - - > .button - display inline-block - flex-shrink 0 - width 20px - height 20px - background none - border solid 2px var(--radioBorder) - border-radius 100% - transition inherit - - &:after - content '' - display block - position absolute - top 3px - right 3px - bottom 3px - left 3px - border-radius 100% - opacity 0 - transform scale(0) - transition 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) - - > .label - margin-left 8px - display block - font-size 14px - line-height 20px - cursor pointer - -</style> diff --git a/src/client/app/common/views/components/ui/horizon-group.vue b/src/client/app/common/views/components/ui/horizon-group.vue deleted file mode 100644 index 33d0300101..0000000000 --- a/src/client/app/common/views/components/ui/horizon-group.vue +++ /dev/null @@ -1,76 +0,0 @@ -<template> -<div class="vnxwkwuf" :class="{ inputs, noGrow }" :data-children-count="children"> - <slot></slot> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - provide: { - horizonGrouped: true - }, - props: { - inputs: { - type: Boolean, - required: false, - default: false - }, - noGrow: { - type: Boolean, - required: false, - default: false - } - }, - data() { - return { - children: 0 - }; - }, - mounted() { - this.$nextTick(() => { - this.children = this.$slots.default.length; - }); - } -}); -</script> - -<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 !important - - &[data-children-count="3"] - @media (max-width 600px) - display block - - > * - display block - width 100% !important - margin 16px 0 !important - - &:first-child - margin-top 0 !important - - &:last-child - margin-bottom 0 !important - -</style> diff --git a/src/client/app/common/views/components/ui/info.vue b/src/client/app/common/views/components/ui/info.vue deleted file mode 100644 index 30fd8cb344..0000000000 --- a/src/client/app/common/views/components/ui/info.vue +++ /dev/null @@ -1,43 +0,0 @@ -<template> -<div class="ymxyweixqwsxauxldgpvecjepnwxbylu" :class="{ warn }"> - <i v-if="warn"><fa icon="exclamation-triangle"/></i> - <i v-else><fa icon="info-circle"/></i> - <slot></slot> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - props: { - warn: { - type: Boolean, - required: false, - default: false - }, - }, -}); -</script> - -<style lang="stylus" scoped> -.ymxyweixqwsxauxldgpvecjepnwxbylu - margin 16px 0 - padding 16px - font-size 90% - background var(--infoBg) - color var(--infoFg) - - &.warn - background var(--infoWarnBg) - color var(--infoWarnFg) - - &:first-child - margin-top 0 - - &:last-child - margin-bottom 0 - - > i - margin-right 4px - -</style> diff --git a/src/client/app/common/views/components/ui/input.vue b/src/client/app/common/views/components/ui/input.vue deleted file mode 100644 index 1b339a9ae0..0000000000 --- a/src/client/app/common/views/components/ui/input.vue +++ /dev/null @@ -1,503 +0,0 @@ -<template> -<div class="ui-input" :class="[{ focused, filled, inline, disabled }, styl]"> - <div class="icon" ref="icon"><slot name="icon"></slot></div> - <div class="input"> - <div class="password-meter" v-if="withPasswordMeter" v-show="passwordStrength != ''" :data-strength="passwordStrength"> - <div class="value" ref="passwordMetar"></div> - </div> - <span class="label" ref="label"><slot></slot></span> - <span class="title" ref="title"> - <slot name="title"></slot> - <span class="warning" v-if="invalid"><fa :icon="['fa', 'exclamation-circle']"/>{{ $refs.input.validationMessage }}</span> - </span> - <div class="prefix" ref="prefix"><slot name="prefix"></slot></div> - <template v-if="type != 'file'"> - <input v-if="debounce" ref="input" - v-debounce="500" - :type="type" - v-model.lazy="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)" - @change="$emit('change', $event)" - :list="id" - > - <input v-else ref="input" - :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)" - @change="$emit('change', $event)" - :list="id" - > - <datalist :id="id" v-if="datalist"> - <option v-for="data in datalist" :value="data"/> - </datalist> - </template> - <template v-else> - <input ref="input" - type="text" - :value="filePlaceholder" - readonly - @click="chooseFile" - > - <input ref="file" - type="file" - :value="value" - @change="onChangeFile" - > - </template> - <div class="suffix" ref="suffix"><slot name="suffix"></slot></div> - </div> - <div class="toggle" v-if="withPasswordToggle"> - <a @click="togglePassword"> - <span v-if="type == 'password'"><fa :icon="['fa', 'eye']"/> {{ $t('@.show-password') }}</span> - <span v-if="type != 'password'"><fa :icon="['far', 'eye-slash']"/> {{ $t('@.hide-password') }}</span> - </a> - </div> - <div class="desc"><slot name="desc"></slot></div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import debounce from 'v-debounce'; -const getPasswordStrength = require('syuilo-password-strength'); - -export default Vue.extend({ - directives: { - debounce - }, - inject: { - horizonGrouped: { - default: false - } - }, - props: { - value: { - required: false - }, - type: { - type: String, - required: false - }, - required: { - type: Boolean, - required: false - }, - readonly: { - type: Boolean, - required: false - }, - disabled: { - type: Boolean, - required: false - }, - pattern: { - type: String, - required: false - }, - placeholder: { - type: String, - required: false - }, - autofocus: { - type: Boolean, - required: false, - default: false - }, - autocomplete: { - required: false - }, - spellcheck: { - required: false - }, - debounce: { - required: false - }, - withPasswordMeter: { - type: Boolean, - required: false, - default: false - }, - withPasswordToggle: { - type: Boolean, - required: false, - default: false - }, - datalist: { - type: Array, - required: false, - }, - inline: { - type: Boolean, - required: false, - default(): boolean { - return this.horizonGrouped; - } - }, - styl: { - type: String, - required: false, - default: 'line' - } - }, - data() { - return { - v: this.value, - focused: false, - invalid: false, - passwordStrength: '', - id: Math.random().toString() - }; - }, - computed: { - filled(): boolean { - return this.v != '' && this.v != null; - }, - filePlaceholder(): string { - if (this.type != 'file') return null; - if (this.v == null) return null; - - if (typeof this.v == 'string') return this.v; - - if (Array.isArray(this.v)) { - return this.v.map(file => file.name).join(', '); - } else { - return this.v.name; - } - } - }, - watch: { - value(v) { - this.v = v; - }, - v(v) { - if (this.type === 'number') { - this.$emit('input', parseInt(v, 10)); - } else { - this.$emit('input', v); - } - - if (this.withPasswordMeter) { - if (v == '') { - this.passwordStrength = ''; - return; - } - - const strength = getPasswordStrength(v); - this.passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; - (this.$refs.passwordMetar as any).style.width = `${strength * 100}%`; - } - - this.invalid = this.$refs.input.validity.badInput; - } - }, - mounted() { - if (this.autofocus) { - this.$nextTick(() => { - this.$refs.input.focus(); - }); - } - - this.$nextTick(() => { - // このコンポーネントが作成された時、非表示状態である場合がある - // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する - const clock = setInterval(() => { - if (this.$refs.prefix) { - this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px'; - if (this.$refs.prefix.offsetWidth) { - this.$refs.input.style.paddingLeft = this.$refs.prefix.offsetWidth + 'px'; - } - } - if (this.$refs.suffix) { - if (this.$refs.suffix.offsetWidth) { - this.$refs.input.style.paddingRight = this.$refs.suffix.offsetWidth + 'px'; - } - } - }, 100); - - this.$once('hook:beforeDestroy', () => { - clearInterval(clock); - }); - }); - - this.$on('keydown', (e: KeyboardEvent) => { - if (e.code == 'Enter') { - this.$emit('enter'); - } - }); - }, - methods: { - focus() { - this.$refs.input.focus(); - }, - togglePassword() { - if (this.type == 'password') { - this.type = 'text' - } else { - this.type = 'password' - } - }, - chooseFile() { - this.$refs.file.click(); - }, - onChangeFile() { - this.v = Array.from((this.$refs.file as any).files); - this.$emit('input', this.v); - this.$emit('change', this.v); - } - } -}); -</script> - -<style lang="stylus" scoped> -root(fill) - margin 32px 0 - - > .icon - position absolute - top 0 - left 0 - width 24px - text-align center - line-height 32px - color var(--inputLabel) - - &:not(:empty) + .input - margin-left 28px - - > .input - - if !fill - &:before - content '' - display block - position absolute - bottom 0 - left 0 - right 0 - height 1px - background var(--inputBorder) - - &:after - content '' - display block - position absolute - bottom 0 - left 0 - right 0 - height 2px - background var(--primary) - opacity 0 - transform scaleX(0.12) - transition border 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1) - will-change border opacity transform - - > .password-meter - position absolute - top 0 - left 0 - width 100% - height 100% - border-radius 6px - overflow hidden - opacity 0.3 - - &[data-strength=''] - display none - - &[data-strength='low'] - > .value - background #d73612 - - &[data-strength='medium'] - > .value - background #d7ca12 - - &[data-strength='high'] - > .value - background #61bb22 - - > .value - display block - width 0 - height 100% - background transparent - border-radius 6px - transition all 0.1s ease - - > .label - position absolute - z-index 1 - top fill ? 6px : 0 - left 0 - pointer-events none - transition 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) - transition-duration 0.3s - font-size 16px - line-height 32px - color var(--inputLabel) - pointer-events none - //will-change transform - transform-origin top left - transform scale(1) - - > .title - position absolute - z-index 1 - top fill ? -24px : -17px - left 0 !important - pointer-events none - font-size 16px - line-height 32px - color var(--inputLabel) - pointer-events none - //will-change transform - transform-origin top left - transform scale(.75) - white-space nowrap - width 133% - overflow hidden - text-overflow ellipsis - - > .warning - margin-left 0.5em - color var(--infoWarnFg) - - > svg - margin-right 0.1em - - > input - display block - width 100% - margin 0 - padding 0 - font inherit - font-weight fill ? bold : normal - font-size 16px - line-height 32px - color var(--inputText) - background transparent - border none - border-radius 0 - outline none - box-shadow none - - if fill - padding 6px 12px - background rgba(#000, 0.035) - border-radius 6px - - &[type='file'] - display none - - > .prefix - > .suffix - display block - position absolute - z-index 1 - top 0 - font-size 16px - line-height fill ? 44px : 32px - color var(--inputLabel) - pointer-events none - - &:empty - display none - - > * - display inline-block - min-width 16px - max-width 150px - overflow hidden - white-space nowrap - text-overflow ellipsis - - > .prefix - left 0 - padding-right 4px - - if fill - padding-left 12px - - > .suffix - right 0 - padding-left 4px - - if fill - padding-right 12px - - > .toggle - cursor pointer - padding-left 0.5em - font-size 0.7em - opacity 0.7 - text-align left - - > a - color var(--inputLabel) - text-decoration none - - > .desc - margin 6px 0 - font-size 13px - - &:empty - display none - - * - margin 0 - - &.focused - > .input - if fill - background rgba(#000, 0.05) - else - &:after - opacity 1 - transform scaleX(1) - - > .label - color var(--primary) - - &.focused - &.filled - > .input - > .label - top fill ? -24px : -17px - left 0 !important - transform scale(0.75) - -.ui-input - &.fill - root(true) - &: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/margin.vue b/src/client/app/common/views/components/ui/margin.vue deleted file mode 100644 index 508116f070..0000000000 --- a/src/client/app/common/views/components/ui/margin.vue +++ /dev/null @@ -1,16 +0,0 @@ -<template> -<div class="zdcrxcne"> - <slot></slot> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({}); -</script> - -<style lang="stylus" scoped> -.zdcrxcne - margin 16px - -</style> diff --git a/src/client/app/common/views/components/ui/modal.vue b/src/client/app/common/views/components/ui/modal.vue deleted file mode 100644 index 413dc39fa5..0000000000 --- a/src/client/app/common/views/components/ui/modal.vue +++ /dev/null @@ -1,80 +0,0 @@ -<template> -<div class="modal"> - <div class="bg" ref="bg" @click="onBgClick" /> - <slot class="main" /> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import anime from 'animejs'; - -export default Vue.extend({ - props: { - closeOnBgClick: { - type: Boolean, - required: false, - default: true - }, - openAnimeDuration: { - type: Number, - required: false, - default: 100 - }, - closeAnimeDuration: { - type: Number, - required: false, - default: 100 - } - }, - mounted() { - anime({ - targets: this.$refs.bg, - opacity: 1, - duration: this.openAnimeDuration, - easing: 'linear' - }); - }, - methods: { - onBgClick() { - this.$emit('bg-click'); - if (this.closeOnBgClick) this.close(); - }, - close() { - this.$emit('before-close'); - - anime({ - targets: this.$refs.bg, - opacity: 0, - duration: this.closeAnimeDuration, - easing: 'linear', - complete: () => (this as any).destroyDom() - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.modal - position fixed - z-index 2048 - top 0 - left 0 - width 100% - height 100% - -.bg - display block - position fixed - z-index 1 - top 0 - left 0 - width 100% - height 100% - background rgba(#000, 0.7) - opacity 0 - -.main - z-index 1 -</style> diff --git a/src/client/app/common/views/components/ui/pagination.vue b/src/client/app/common/views/components/ui/pagination.vue deleted file mode 100644 index 67aa89d369..0000000000 --- a/src/client/app/common/views/components/ui/pagination.vue +++ /dev/null @@ -1,36 +0,0 @@ -<template> -<div class="mwermpua" v-if="!fetching"> - <sequential-entrance animation="entranceFromTop" delay="25"> - <slot :items="items"></slot> - </sequential-entrance> - <div class="more" v-if="more"> - <ui-button @click="fetchMore()">{{ $t('@.load-more') }}</ui-button> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import paging from '../../../scripts/paging'; - -export default Vue.extend({ - mixins: [ - paging({ - captureWindowScroll: false, - }), - ], - - props: { - pagination: { - required: true - }, - }, -}); -</script> - -<style lang="stylus" scoped> -.mwermpua - > .more - margin-top 16px - -</style> diff --git a/src/client/app/common/views/components/ui/radio.vue b/src/client/app/common/views/components/ui/radio.vue deleted file mode 100644 index 468318b58e..0000000000 --- a/src/client/app/common/views/components/ui/radio.vue +++ /dev/null @@ -1,110 +0,0 @@ -<template> -<div - class="ui-radio" - :class="{ disabled, checked }" - :aria-checked="checked" - :aria-disabled="disabled" - @click="toggle" -> - <input type="radio" - :disabled="disabled" - > - <span class="button"> - <span></span> - </span> - <span class="label"><slot></slot></span> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - model: { - prop: 'model', - event: 'change' - }, - props: { - model: { - required: false - }, - value: { - required: false - }, - disabled: { - type: Boolean, - default: false - } - }, - computed: { - checked(): boolean { - return this.model === this.value; - } - }, - methods: { - toggle() { - this.$emit('change', this.value); - } - } -}); -</script> - -<style lang="stylus" scoped> -.ui-radio - display inline-block - margin 0 32px 0 0 - cursor pointer - transition all 0.3s - - > * - user-select none - - &.disabled - opacity 0.6 - cursor not-allowed - - &.checked - > .button - border-color var(--radioActive) - - &:after - background-color var(--radioActive) - transform scale(1) - opacity 1 - - > input - position absolute - width 0 - height 0 - opacity 0 - margin 0 - - > .button - position absolute - width 20px - height 20px - background none - border solid 2px var(--inputLabel) - border-radius 100% - transition inherit - - &:after - content '' - display block - position absolute - top 3px - right 3px - bottom 3px - left 3px - border-radius 100% - opacity 0 - transform scale(0) - transition 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) - - > .label - margin-left 28px - display block - font-size 16px - line-height 20px - cursor pointer - -</style> diff --git a/src/client/app/common/views/components/ui/select.vue b/src/client/app/common/views/components/ui/select.vue deleted file mode 100644 index 1057d60d07..0000000000 --- a/src/client/app/common/views/components/ui/select.vue +++ /dev/null @@ -1,238 +0,0 @@ -<template> -<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" - v-model="v" - :required="required" - :disabled="disabled" - @focus="focused = true" - @blur="focused = false" - > - <slot></slot> - </select> - <div class="suffix"><slot name="suffix"></slot></div> - </div> - <div class="text"><slot name="text"></slot></div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - inject: { - horizonGrouped: { - default: false - } - }, - props: { - value: { - required: false - }, - required: { - 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 { - focused: false - }; - }, - computed: { - v: { - get() { - return this.value; - }, - set(v) { - this.$emit('input', v); - } - }, - filled(): boolean { - return this.v != '' && this.v != null; - } - }, - mounted() { - if (this.$refs.prefix) { - this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px'; - } - }, - methods: { - focus() { - this.$refs.input.focus(); - } - } -}); -</script> - -<style lang="stylus" scoped> -root(fill) - margin 32px 0 - - > .icon - position absolute - top 0 - left 0 - width 24px - text-align center - line-height 32px - color var(--inputLabel) - - &:not(:empty) + .input - margin-left 28px - - > .input - display flex - - if fill - padding 6px 12px - background rgba(#000, 0.035) - border-radius 6px - else - &:before - content '' - display block - position absolute - bottom 0 - left 0 - right 0 - height 1px - background var(--inputBorder) - - &:after - content '' - display block - position absolute - bottom 0 - left 0 - right 0 - height 2px - background var(--primary) - opacity 0 - transform scaleX(0.12) - transition border 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1) - will-change border opacity transform - - > .label - position absolute - top fill ? 6px : 0 - left 0 - pointer-events none - transition 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) - transition-duration 0.3s - font-size 16px - line-height 32px - color var(--inputLabel) - pointer-events none - //will-change transform - transform-origin top left - transform scale(1) - - > select - display block - flex 1 - width 100% - padding 0 - font inherit - font-weight fill ? bold : normal - font-size 16px - height 32px - color var(--inputText) - background transparent - border none - border-radius 0 - outline none - box-shadow none - - * - color #000 - - > .prefix - > .suffix - display block - align-self center - justify-self center - font-size 16px - line-height 32px - color rgba(#000, 0.54) - pointer-events none - - &:empty - display none - - > * - display block - min-width 16px - - > .prefix - padding-right 4px - - > .suffix - padding-left 4px - - > .text - margin 6px 0 - font-size 13px - - &:empty - display none - - * - margin 0 - - &.focused - > .input - if fill - background rgba(#000, 0.05) - else - &:after - opacity 1 - transform scaleX(1) - - > .label - color var(--primary) - - &.focused - &.filled - > .input - > .label - top fill ? -24px : -17px - left 0 !important - transform scale(0.75) - -.ui-select - &.fill - root(true) - &: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 deleted file mode 100644 index 8e3997ae78..0000000000 --- a/src/client/app/common/views/components/ui/switch.vue +++ /dev/null @@ -1,135 +0,0 @@ -<template> -<div - class="ui-switch" - :class="{ disabled, checked }" - role="switch" - :aria-checked="checked" - :aria-disabled="disabled" - @click="toggle" -> - <input - type="checkbox" - ref="input" - :disabled="disabled" - @keydown.enter="toggle" - > - <span class="button"> - <span></span> - </span> - <span class="label"> - <span :aria-hidden="!checked"><slot></slot></span> - <p :aria-hidden="!checked"> - <slot name="desc"></slot> - </p> - </span> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - model: { - prop: 'value', - event: 'change' - }, - props: { - value: { - type: Boolean, - default: false - }, - disabled: { - type: Boolean, - default: false - } - }, - computed: { - checked(): boolean { - return this.value; - } - }, - methods: { - toggle() { - if (this.disabled) return; - this.$emit('change', !this.checked); - } - } -}); -</script> - -<style lang="stylus" scoped> -.ui-switch - display flex - margin 32px 0 - cursor pointer - transition all 0.3s - - &:first-child - margin-top 0 - - &:last-child - margin-bottom 0 - - > * - user-select none - - &.disabled - opacity 0.6 - cursor not-allowed - - &.checked - > .button - background-color var(--switchActiveTrack) - border-color var(--switchActiveTrack) - - > * - background-color var(--switchActive) - transform translateX(14px) - - > input - position absolute - width 0 - height 0 - opacity 0 - margin 0 - - > .button - display inline-block - flex-shrink 0 - margin 3px 0 0 0 - width 34px - height 14px - background var(--switchTrack) - outline none - border-radius 14px - transition inherit - - > * - position absolute - top -3px - left 0 - border-radius 100% - transition background-color 0.3s, transform 0.3s - width 20px - height 20px - background-color #fff - box-shadow 0 2px 1px -1px rgba(#000, 0.2), 0 1px 1px 0 rgba(#000, 0.14), 0 1px 3px 0 rgba(#000, 0.12) - - > .label - margin-left 8px - display block - font-size 16px - cursor pointer - transition inherit - color var(--text) - - > span - display block - line-height 20px - transition inherit - - > p - margin 0 - opacity 0.7 - font-size 90% - -</style> diff --git a/src/client/app/common/views/components/ui/textarea.vue b/src/client/app/common/views/components/ui/textarea.vue deleted file mode 100644 index d265c7ac6d..0000000000 --- a/src/client/app/common/views/components/ui/textarea.vue +++ /dev/null @@ -1,194 +0,0 @@ -<template> -<div class="ui-textarea" :class="{ focused, filled, tall, pre }"> - <div class="input"> - <span class="label" ref="label"><slot></slot></span> - <textarea ref="input" - :value="value" - :required="required" - :readonly="readonly" - :pattern="pattern" - :autocomplete="autocomplete" - @input="$emit('input', $event.target.value)" - @focus="focused = true" - @blur="focused = false" - ></textarea> - </div> - <div class="desc"><slot name="desc"></slot></div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: { - value: { - required: false - }, - required: { - type: Boolean, - required: false - }, - readonly: { - type: Boolean, - required: false - }, - pattern: { - type: String, - required: false - }, - autocomplete: { - type: String, - required: false - }, - tall: { - type: Boolean, - required: false, - default: false - }, - pre: { - type: Boolean, - required: false, - default: false - }, - }, - data() { - return { - focused: false, - passwordStrength: '' - } - }, - computed: { - filled(): boolean { - return this.value != '' && this.value != null; - } - }, - methods: { - focus() { - this.$refs.input.focus(); - } - } -}); -</script> - -<style lang="stylus" scoped> -root(fill) - margin 42px 0 32px 0 - - &:last-child - margin-bottom 0 - - > .input - padding 12px - - if fill - background rgba(#000, 0.035) - border-radius 6px - else - &:before - content '' - display block - position absolute - top 0 - bottom 0 - left 0 - right 0 - background none - border solid 1px var(--inputBorder) - border-radius 3px - pointer-events none - - &:after - content '' - display block - position absolute - top 0 - bottom 0 - left 0 - right 0 - background none - border solid 2px var(--primary) - border-radius 3px - opacity 0 - transition opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1) - pointer-events none - - > .label - position absolute - top 6px - left 12px - pointer-events none - transition 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) - transition-duration 0.3s - font-size 16px - line-height 32px - color var(--inputLabel) - pointer-events none - //will-change transform - transform-origin top left - transform scale(1) - - > textarea - display block - width 100% - min-width 100% - max-width 100% - min-height 100px - padding 0 - font inherit - font-weight fill ? bold : normal - font-size 16px - color var(--inputText) - background transparent - border none - border-radius 0 - outline none - box-shadow none - - > .desc - margin 6px 0 - font-size 13px - opacity 0.7 - - &:empty - display none - - * - margin 0 - - &.focused - > .input - if fill - background rgba(#000, 0.05) - else - &:after - opacity 1 - - > .label - color var(--primary) - - &.focused - &.filled - > .input - > .label - top -24px - left 0 !important - transform scale(0.75) - - &.tall - > .input - > textarea - min-height 200px - - &.pre - > .input - > textarea - white-space pre - -.ui-textarea.fill - root(true) - -.ui-textarea:not(.fill) - root(false) - -</style> diff --git a/src/client/app/common/views/components/uploader.vue b/src/client/app/common/views/components/uploader.vue deleted file mode 100644 index 9f02da6c1e..0000000000 --- a/src/client/app/common/views/components/uploader.vue +++ /dev/null @@ -1,231 +0,0 @@ -<template> -<div class="mk-uploader"> - <ol v-if="uploads.length > 0"> - <li v-for="ctx in uploads" :key="ctx.id"> - <div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div> - <div class="top"> - <p class="name"><fa icon="spinner" pulse/>{{ ctx.name }}</p> - <p class="status"> - <span class="initing" v-if="ctx.progress == undefined">{{ $t('waiting') }}<mk-ellipsis/></span> - <span class="kb" v-if="ctx.progress != undefined">{{ String(Math.floor(ctx.progress.value / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progress.max / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span> - <span class="percentage" v-if="ctx.progress != undefined">{{ Math.floor((ctx.progress.value / ctx.progress.max) * 100) }}</span> - </p> - </div> - <progress v-if="ctx.progress != undefined && ctx.progress.value != ctx.progress.max" :value="ctx.progress.value" :max="ctx.progress.max"></progress> - <div class="progress initing" v-if="ctx.progress == undefined"></div> - <div class="progress waiting" v-if="ctx.progress != undefined && ctx.progress.value == ctx.progress.max"></div> - </li> - </ol> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { apiUrl } from '../../../config'; -import getMD5 from '../../scripts/get-md5'; - -export default Vue.extend({ - i18n: i18n('common/views/components/uploader.vue'), - data() { - return { - uploads: [] - }; - }, - methods: { - checkExistence(fileData: ArrayBuffer): Promise<any> { - return new Promise((resolve, reject) => { - const data = new FormData(); - data.append('md5', getMD5(fileData)); - - this.$root.api('drive/files/find-by-hash', { - md5: getMD5(fileData) - }).then(resp => { - resolve(resp.length > 0 ? resp[0] : null); - }); - }); - }, - - upload(file: File, folder: any, name?: string) { - if (folder && typeof folder == 'object') folder = folder.id; - - const id = Math.random(); - - const reader = new FileReader(); - reader.onload = (e: any) => { - this.checkExistence(e.target.result).then(result => { - if (result !== null) { - this.$emit('uploaded', result); - return; - } - - const ctx = { - id: id, - name: name || file.name || 'untitled', - progress: undefined, - img: window.URL.createObjectURL(file) - }; - - this.uploads.push(ctx); - this.$emit('change', this.uploads); - - const data = new FormData(); - data.append('i', this.$store.state.i.token); - data.append('force', 'true'); - data.append('file', file); - - if (folder) data.append('folderId', folder); - if (name) data.append('name', name); - - const xhr = new XMLHttpRequest(); - xhr.open('POST', apiUrl + '/drive/files/create', true); - xhr.onload = (e: any) => { - const driveFile = JSON.parse(e.target.response); - - this.$emit('uploaded', driveFile); - - this.uploads = this.uploads.filter(x => x.id != id); - this.$emit('change', this.uploads); - }; - - xhr.upload.onprogress = e => { - if (e.lengthComputable) { - if (ctx.progress == undefined) ctx.progress = {}; - ctx.progress.max = e.total; - ctx.progress.value = e.loaded; - } - }; - - xhr.send(data); - }) - } - reader.readAsArrayBuffer(file); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-uploader - overflow auto - - &:empty - display none - - > ol - display block - margin 0 - padding 0 - list-style none - - > li - display grid - margin 8px 0 0 0 - padding 0 - height 36px - width: 100% - box-shadow 0 -1px 0 var(--primaryAlpha01) - border-top solid 8px transparent - grid-template-columns 36px calc(100% - 44px) - grid-template-rows 1fr 8px - column-gap 8px - box-sizing content-box - - &:first-child - margin 0 - box-shadow none - border-top none - - > .img - display block - background-size cover - background-position center center - grid-column 1 / 2 - grid-row 1 / 3 - - > .top - display flex - grid-column 2 / 3 - grid-row 1 / 2 - - > .name - display block - padding 0 8px 0 0 - margin 0 - font-size 0.8em - color var(--primaryAlpha07) - white-space nowrap - text-overflow ellipsis - overflow hidden - flex-shrink 1 - - > [data-icon] - margin-right 4px - - > .status - display block - margin 0 0 0 auto - padding 0 - font-size 0.8em - flex-shrink 0 - - > .initing - color var(--primaryAlpha05) - - > .kb - color var(--primaryAlpha05) - - > .percentage - display inline-block - width 48px - text-align right - - color var(--primaryAlpha07) - - &:after - content '%' - - > progress - display block - background transparent - border none - border-radius 4px - overflow hidden - grid-column 2 / 3 - grid-row 2 / 3 - z-index 2 - - &::-webkit-progress-value - background var(--primary) - - &::-webkit-progress-bar - background var(--primaryAlpha01) - - > .progress - display block - border none - border-radius 4px - background linear-gradient( - 45deg, - var(--primaryLighten30) 25%, - var(--primary) 25%, - var(--primary) 50%, - var(--primaryLighten30) 50%, - var(--primaryLighten30) 75%, - var(--primary) 75%, - var(--primary) - ) - background-size 32px 32px - animation bg 1.5s linear infinite - grid-column 2 / 3 - grid-row 2 / 3 - z-index 1 - - &.initing - opacity 0.3 - - @keyframes bg - from {background-position: 0 0;} - to {background-position: -64px 32px;} - -</style> diff --git a/src/client/app/common/views/components/url-preview.vue b/src/client/app/common/views/components/url-preview.vue deleted file mode 100644 index 80aae5999d..0000000000 --- a/src/client/app/common/views/components/url-preview.vue +++ /dev/null @@ -1,343 +0,0 @@ -<template> -<div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`"> - <button class="disablePlayer" @click="playerEnabled = false" :title="$t('disable-player')"><fa icon="times"/></button> - <iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen /> -</div> -<div v-else-if="tweetUrl && detail" class="twitter"> - <blockquote ref="tweet" class="twitter-tweet" :data-theme="$store.state.device.darkmode ? 'dark' : null"> - <a :href="url"></a> - </blockquote> -</div> -<div v-else class="mk-url-preview"> - <component :is="hasRoute ? 'router-link' : 'a'" :class="{ mini: narrow, compact }" :[attr]="hasRoute ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url" v-if="!fetching"> - <div class="thumbnail" v-if="thumbnail" :style="`background-image: url('${thumbnail}')`"> - <button v-if="!playerEnabled && player.url" @click.prevent="playerEnabled = true" :title="$t('enable-player')"><fa :icon="['far', 'play-circle']"/></button> - </div> - <article> - <header> - <h1 :title="title">{{ title }}</h1> - </header> - <p v-if="description" :title="description">{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p> - <footer> - <img class="icon" v-if="icon" :src="icon"/> - <p :title="sitename">{{ sitename }}</p> - </footer> - </article> - </component> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { url as local, lang } from '../../../config'; - -export default Vue.extend({ - i18n: i18n('common/views/components/url-preview.vue'), - - props: { - url: { - type: String, - require: true - }, - - detail: { - type: Boolean, - required: false, - default: false - }, - - compact: { - type: Boolean, - required: false, - default: false - }, - }, - - inject: { - narrow: { - default: false - } - }, - - data() { - const isSelf = this.url.startsWith(local); - const hasRoute = - (this.url.substr(local.length) === '/') || - this.url.substr(local.length).startsWith('/@') || - this.url.substr(local.length).startsWith('/notes/') || - this.url.substr(local.length).startsWith('/tags/') || - this.url.substr(local.length).startsWith('/pages/'); - return { - local, - fetching: true, - title: null, - description: null, - thumbnail: null, - icon: null, - sitename: null, - player: { - url: null, - width: null, - height: null - }, - tweetUrl: null, - playerEnabled: false, - self: isSelf, - hasRoute: hasRoute, - attr: hasRoute ? 'to' : 'href', - target: hasRoute ? null : '_blank' - }; - }, - - created() { - const requestUrl = new URL(this.url); - - if (this.detail && requestUrl.hostname == 'twitter.com' && /^\/.+\/status(es)?\/\d+/.test(requestUrl.pathname)) { - this.tweetUrl = requestUrl; - const twttr = (window as any).twttr || {}; - const loadTweet = () => twttr.widgets.load(this.$refs.tweet); - - if (twttr.widgets) { - Vue.nextTick(loadTweet); - } else { - const wjsId = 'twitter-wjs'; - if (!document.getElementById(wjsId)) { - const head = document.getElementsByTagName('head')[0]; - const script = document.createElement('script'); - script.setAttribute('id', wjsId); - script.setAttribute('src', 'https://platform.twitter.com/widgets.js'); - head.appendChild(script); - } - twttr.ready = loadTweet; - (window as any).twttr = twttr; - } - return; - } - - if (requestUrl.hostname === 'music.youtube.com') { - requestUrl.hostname = 'youtube.com'; - } - - const requestLang = (lang || 'ja-JP').replace('ja-KS', 'ja-JP'); - - requestUrl.hash = ''; - - fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => { - res.json().then(info => { - if (info.url == null) return; - this.title = info.title; - this.description = info.description; - this.thumbnail = info.thumbnail; - this.icon = info.icon; - this.sitename = info.sitename; - this.fetching = false; - this.player = info.player; - }) - }); - } -}); -</script> - -<style lang="stylus" scoped> -.player - position relative - width 100% - - > button - position absolute - top -1.5em - right 0 - font-size 1em - width 1.5em - height 1.5em - padding 0 - margin 0 - color var(--text) - background rgba(128, 128, 128, 0.2) - opacity 0.7 - - &:hover - opacity 0.9 - - > iframe - height 100% - left 0 - position absolute - top 0 - width 100% - -.mk-url-preview - > a - display block - font-size 14px - border solid var(--lineWidth) var(--urlPreviewBorder) - border-radius 4px - overflow hidden - - &:hover - text-decoration none - border-color var(--urlPreviewBorderHover) - - > article > header > h1 - text-decoration underline - - > .thumbnail - position absolute - width 100px - height 100% - background-position center - background-size cover - display flex - justify-content center - align-items center - - > button - font-size 3.5em - opacity: 0.7 - - &:hover - font-size 4em - opacity 0.9 - - & + article - left 100px - width calc(100% - 100px) - - > article - padding 16px - - > header - margin-bottom 8px - - > h1 - margin 0 - font-size 1em - color var(--urlPreviewTitle) - - > p - margin 0 - color var(--urlPreviewText) - font-size 0.8em - - > footer - margin-top 8px - height 16px - - > img - display inline-block - width 16px - height 16px - margin-right 4px - vertical-align top - - > p - display inline-block - margin 0 - color var(--urlPreviewInfo) - font-size 0.8em - line-height 16px - vertical-align top - - @media (max-width 700px) - > .thumbnail - position relative - width 100% - height 100px - - & + article - left 0 - width 100% - - @media (max-width 550px) - font-size 12px - - > .thumbnail - height 80px - - > article - padding 12px - - @media (max-width 500px) - font-size 10px - - > .thumbnail - height 70px - - > article - padding 8px - - > header - margin-bottom 4px - - > footer - margin-top 4px - - > img - width 12px - height 12px - - &.compact - > .thumbnail - position: absolute - width 56px - height 100% - - > article - left 56px - width calc(100% - 56px) - padding 4px - - > header - margin-bottom 2px - - > footer - margin-top 2px - - &.mini - font-size 10px - - > .thumbnail - position relative - width 100% - height 60px - - > article - left 0 - width 100% - padding 8px - - > header - margin-bottom 4px - - > footer - margin-top 4px - - > img - width 12px - height 12px - - &.compact - > .thumbnail - position absolute - width 56px - height 100% - - > article - left 56px - width calc(100% - 56px) - padding 4px - - > header - margin-bottom 2px - - > footer - margin-top 2px - - &.compact - > article - > header h1, p, footer - overflow hidden - white-space nowrap - text-overflow ellipsis -</style> diff --git a/src/client/app/common/views/components/user-list.vue b/src/client/app/common/views/components/user-list.vue deleted file mode 100644 index 4ba4e67e54..0000000000 --- a/src/client/app/common/views/components/user-list.vue +++ /dev/null @@ -1,165 +0,0 @@ -<template> -<ui-container :body-togglable="true" :expanded="expanded"> - <template #header><slot></slot></template> - - <mk-error v-if="error" @retry="init()"/> - - <div class="efvhhmdq" :class="{ iconOnly }" v-size="[{ lt: 500, class: 'narrow' }]"> - <div class="no-users" v-if="empty"> - <p>{{ $t('no-users') }}</p> - </div> - <div class="user" v-for="user in users" :key="user.id"> - <mk-avatar class="avatar" :user="user"/> - <div class="body" v-if="!iconOnly"> - <div class="name"> - <router-link class="name" :to="user | userPage" v-user-preview="user.id"><mk-user-name :user="user"/></router-link> - <p class="username">@{{ user | acct }}</p> - </div> - <div class="description" v-if="user.description" :title="user.description"> - <mfm :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :plain="true" :nowrap="true"/> - </div> - <mk-follow-button class="koudoku-button" v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" mini/> - </div> - </div> - <button class="more" :class="{ fetching: moreFetching }" v-if="more" @click="fetchMore()" :disabled="moreFetching"> - <template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template>{{ moreFetching ? $t('@.loading') : $t('@.load-more') }} - </button> - </div> -</ui-container> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import paging from '../../../common/scripts/paging'; - -export default Vue.extend({ - i18n: i18n('common/views/components/user-list.vue'), - - mixins: [ - paging({}), - ], - - props: { - pagination: { - required: true - }, - extract: { - required: false - }, - iconOnly: { - type: Boolean, - default: false - }, - expanded: { - type: Boolean, - default: true - }, - }, - - computed: { - users() { - return this.extract ? this.extract(this.items) : this.items; - } - } -}); -</script> - -<style lang="stylus" scoped> -.efvhhmdq - &.narrow - > .user > .body > .name - width 100% - - > .user > .body > .description - display none - - &.iconOnly - padding 12px - - > .user - display inline-block - padding 0 - border-bottom none - - > .avatar - display inline-block - margin 4px - - > .no-users - text-align center - color var(--text) - - > .user - display flex - padding 16px - border-bottom solid 1px var(--faceDivider) - - &:last-child - border-bottom none - - > .avatar - display block - flex-shrink 0 - margin 0 12px 0 0 - width 42px - height 42px - border-radius 8px - - > .body - display flex - width calc(100% - 54px) - - > .name - width 45% - - > .name - margin 0 - font-size 16px - line-height 24px - color var(--text) - - > .username - display block - margin 0 - font-size 15px - line-height 16px - color var(--text) - opacity 0.7 - - > .description - width 55% - color var(--text) - line-height 42px - white-space nowrap - overflow hidden - text-overflow ellipsis - opacity 0.7 - font-size 14px - padding-right 40px - - > .koudoku-button - position absolute - top 8px - right 0 - - > .more - display block - width 100% - padding 16px - color var(--text) - border-top solid var(--lineWidth) rgba(#000, 0.05) - - &:hover - background rgba(#000, 0.025) - - &:active - background rgba(#000, 0.05) - - &.fetching - cursor wait - - > [data-icon] - margin-right 4px - -</style> diff --git a/src/client/app/common/views/components/user-menu.vue b/src/client/app/common/views/components/user-menu.vue deleted file mode 100644 index 532dcf35c2..0000000000 --- a/src/client/app/common/views/components/user-menu.vue +++ /dev/null @@ -1,228 +0,0 @@ -<template> -<div style="position:initial"> - <mk-menu :source="source" :items="items" @closed="closed"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { faExclamationCircle, faMicrophoneSlash } from '@fortawesome/free-solid-svg-icons'; -import { faSnowflake } from '@fortawesome/free-regular-svg-icons'; - -export default Vue.extend({ - i18n: i18n('common/views/components/user-menu.vue'), - - props: ['user', 'source'], - - data() { - let menu = [{ - icon: ['fas', 'at'], - text: this.$t('mention'), - action: () => { - this.$post({ mention: this.user }); - } - }, null, { - icon: ['fas', 'list'], - text: this.$t('push-to-list'), - action: this.pushList - }] as any; - - if (this.$store.getters.isSignedIn && this.$store.state.i.id != this.user.id) { - menu = menu.concat([null, { - icon: this.user.isMuted ? ['fas', 'eye'] : ['far', 'eye-slash'], - text: this.user.isMuted ? this.$t('unmute') : this.$t('mute'), - action: this.toggleMute - }, { - icon: 'ban', - text: this.user.isBlocking ? this.$t('unblock') : this.$t('block'), - action: this.toggleBlock - }, null, { - icon: faExclamationCircle, - text: this.$t('report-abuse'), - action: this.reportAbuse - }]); - } - - if (this.$store.getters.isSignedIn && (this.$store.state.i.isAdmin || this.$store.state.i.isModerator)) { - menu = menu.concat([null, { - icon: faMicrophoneSlash, - text: this.user.isSilenced ? this.$t('unsilence') : this.$t('silence'), - action: this.toggleSilence - }, { - icon: faSnowflake, - text: this.user.isSuspended ? this.$t('unsuspend') : this.$t('suspend'), - action: this.toggleSuspend - }]); - } - - return { - items: menu - }; - }, - - methods: { - closed() { - this.$nextTick(() => { - this.destroyDom(); - }); - }, - - async pushList() { - const t = this.$t('select-list'); // なぜか後で参照すると null になるので最初にメモリに確保しておく - const lists = await this.$root.api('users/lists/list'); - const { canceled, result: listId } = await this.$root.dialog({ - type: null, - title: t, - select: { - items: lists.map(list => ({ - value: list.id, text: list.name - })) - }, - showCancelButton: true - }); - if (canceled) return; - await this.$root.api('users/lists/push', { - listId: listId, - userId: this.user.id - }); - this.$root.dialog({ - type: 'success', - splash: true - }); - }, - - async toggleMute() { - if (this.user.isMuted) { - if (!await this.getConfirmed(this.$t('unmute-confirm'))) return; - - this.$root.api('mute/delete', { - userId: this.user.id - }).then(() => { - this.user.isMuted = false; - }, () => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - } else { - if (!await this.getConfirmed(this.$t('mute-confirm'))) return; - - this.$root.api('mute/create', { - userId: this.user.id - }).then(() => { - this.user.isMuted = true; - }, () => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - } - }, - - async toggleBlock() { - if (this.user.isBlocking) { - if (!await this.getConfirmed(this.$t('unblock-confirm'))) return; - - this.$root.api('blocking/delete', { - userId: this.user.id - }).then(() => { - this.user.isBlocking = false; - }, () => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - } else { - if (!await this.getConfirmed(this.$t('block-confirm'))) return; - - this.$root.api('blocking/create', { - userId: this.user.id - }).then(() => { - this.user.isBlocking = true; - }, () => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - } - }, - - async reportAbuse() { - const reported = this.$t('report-abuse-reported'); // なぜか後で参照すると null になるので最初にメモリに確保しておく - const { canceled, result: comment } = await this.$root.dialog({ - title: this.$t('report-abuse-detail'), - input: true - }); - if (canceled) return; - this.$root.api('users/report-abuse', { - userId: this.user.id, - comment: comment - }).then(() => { - this.$root.dialog({ - type: 'success', - text: reported - }); - }, e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - }, - - async toggleSilence() { - if (!await this.getConfirmed(this.$t(this.user.isSilenced ? 'unsilence-confirm' : 'silence-confirm'))) return; - - this.$root.api(this.user.isSilenced ? 'admin/unsilence-user' : 'admin/silence-user', { - userId: this.user.id - }).then(() => { - this.user.isSilenced = !this.user.isSilenced; - this.$root.dialog({ - type: 'success', - splash: true - }); - }, e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - }, - - async toggleSuspend() { - if (!await this.getConfirmed(this.$t(this.user.isSuspended ? 'unsuspend-confirm' : 'suspend-confirm'))) return; - - this.$root.api(this.user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', { - userId: this.user.id - }).then(() => { - this.user.isSuspended = !this.user.isSuspended; - this.$root.dialog({ - type: 'success', - splash: true - }); - }, e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - }, - - async getConfirmed(text: string): Promise<Boolean> { - const confirm = await this.$root.dialog({ - type: 'warning', - showCancelButton: true, - title: 'confirm', - text, - }); - - return !confirm.canceled; - }, - } -}); -</script> diff --git a/src/client/app/common/views/components/visibility-chooser.vue b/src/client/app/common/views/components/visibility-chooser.vue deleted file mode 100644 index 5aa481ed9a..0000000000 --- a/src/client/app/common/views/components/visibility-chooser.vue +++ /dev/null @@ -1,233 +0,0 @@ -<template> -<div class="gqyayizv"> - <div class="backdrop" ref="backdrop" @click="close"></div> - <div class="popover" :class="{ isMobile: $root.isMobile }" ref="popover"> - <div @click="choose('public')" :class="{ active: v == 'public' }"> - <div><fa icon="globe"/></div> - <div> - <span>{{ $t('public') }}</span> - </div> - </div> - <div @click="choose('home')" :class="{ active: v == 'home' }"> - <div><fa icon="home"/></div> - <div> - <span>{{ $t('home') }}</span> - <span>{{ $t('home-desc') }}</span> - </div> - </div> - <div @click="choose('followers')" :class="{ active: v == 'followers' }"> - <div><fa icon="unlock"/></div> - <div> - <span>{{ $t('followers') }}</span> - <span>{{ $t('followers-desc') }}</span> - </div> - </div> - <div @click="choose('specified')" :class="{ active: v == 'specified' }"> - <div><fa icon="envelope"/></div> - <div> - <span>{{ $t('specified') }}</span> - <span>{{ $t('specified-desc') }}</span> - </div> - </div> - <div @click="choose('local-public')" :class="{ active: v == 'local-public' }"> - <div><fa icon="globe"/></div> - <div> - <span>{{ $t('local-public') }}</span> - <span>{{ $t('local-public-desc') }}</span> - </div> - </div> - <div @click="choose('local-home')" :class="{ active: v == 'local-home' }"> - <div><fa icon="home"/></div> - <div> - <span>{{ $t('local-home') }}</span> - </div> - </div> - <div @click="choose('local-followers')" :class="{ active: v == 'local-followers' }"> - <div><fa icon="unlock"/></div> - <div> - <span>{{ $t('local-followers') }}</span> - </div> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import anime from 'animejs'; - -export default Vue.extend({ - i18n: i18n('common/views/components/visibility-chooser.vue'), - props: { - source: { - required: true - }, - currentVisibility: { - type: String, - required: false - } - }, - data() { - return { - v: this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : (this.currentVisibility || this.$store.state.settings.defaultNoteVisibility) - } - }, - mounted() { - this.$nextTick(() => { - const popover = this.$refs.popover as any; - - const rect = this.source.getBoundingClientRect(); - const width = popover.offsetWidth; - const height = popover.offsetHeight; - - let left; - let top; - - if (this.$root.isMobile) { - const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); - const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2); - left = (x - (width / 2)); - top = (y - (height / 2)); - } else { - const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); - const y = rect.top + window.pageYOffset + this.source.offsetHeight; - left = (x - (width / 2)); - top = y; - } - - if (left + width > window.innerWidth) { - left = window.innerWidth - width; - } - - popover.style.left = left + 'px'; - popover.style.top = top + 'px'; - - anime({ - targets: this.$refs.backdrop, - opacity: 1, - duration: 100, - easing: 'linear' - }); - - anime({ - targets: this.$refs.popover, - opacity: 1, - scale: [0.5, 1], - duration: 500 - }); - }); - }, - methods: { - choose(visibility) { - if (this.$store.state.settings.rememberNoteVisibility) { - this.$store.commit('device/setVisibility', visibility); - } - this.$emit('chosen', visibility); - this.destroyDom(); - }, - close() { - (this.$refs.backdrop as any).style.pointerEvents = 'none'; - anime({ - targets: this.$refs.backdrop, - opacity: 0, - duration: 200, - easing: 'linear' - }); - - (this.$refs.popover as any).style.pointerEvents = 'none'; - anime({ - targets: this.$refs.popover, - opacity: 0, - scale: 0.5, - duration: 200, - easing: 'easeInBack', - complete: () => this.destroyDom() - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.gqyayizv - position initial - - > .backdrop - position fixed - top 0 - left 0 - z-index 10000 - width 100% - height 100% - background var(--modalBackdrop) - opacity 0 - - > .popover - $bgcolor = var(--popupBg) - position absolute - z-index 10001 - width 240px - padding 8px 0 - background $bgcolor - border-radius 4px - box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) - transform scale(0.5) - opacity 0 - - &:not(.isMobile) - $arrow-size = 10px - - margin-top $arrow-size - transform-origin center -($arrow-size) - - &:before - content "" - display block - position absolute - top -($arrow-size * 2) - left s('calc(50% - %s)', $arrow-size) - border-top solid $arrow-size transparent - border-left solid $arrow-size transparent - border-right solid $arrow-size transparent - border-bottom solid $arrow-size $bgcolor - - > div - display flex - padding 8px 14px - font-size 12px - color var(--popupFg) - cursor pointer - - &:hover - background var(--faceClearButtonHover) - - &:active - background var(--faceClearButtonActive) - - &.active - color var(--primaryForeground) - background var(--primary) - - > * - user-select none - pointer-events none - - > *:first-child - display flex - justify-content center - align-items center - margin-right 10px - width 16px - - > *:last-child - flex 1 1 auto - - > span:first-child - display block - font-weight bold - - > span:last-child:not(:first-child) - opacity 0.6 - -</style> diff --git a/src/client/app/common/views/components/welcome-timeline.vue b/src/client/app/common/views/components/welcome-timeline.vue deleted file mode 100644 index d812549b1e..0000000000 --- a/src/client/app/common/views/components/welcome-timeline.vue +++ /dev/null @@ -1,156 +0,0 @@ -<template> -<div class="mk-welcome-timeline"> - <transition-group name="ldzpakcixzickvggyixyrhqwjaefknon" tag="div"> - <div v-for="note in notes" :key="note.id"> - <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"> - <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"> - <mk-time :time="note.createdAt"/> - </router-link> - </div> - </header> - <div class="text"> - <mfm v-if="note.text" :text="note.cw != null ? note.cw : note.text" :author="note.user" :custom-emojis="note.emojis"/> - </div> - </div> - </div> - </transition-group> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: { - max: { - type: Number, - required: false, - default: undefined - } - }, - - data() { - return { - fetching: true, - notes: [], - connection: null - }; - }, - - mounted() { - this.fetch(); - - this.connection = this.$root.stream.useSharedConnection('localTimeline'); - - this.connection.on('note', this.onNote); - }, - - beforeDestroy() { - this.connection.dispose(); - }, - - methods: { - fetch(cb?) { - this.fetching = true; - this.$root.api('notes', { - limit: this.max, - local: true, - reply: false, - renote: false, - file: false, - poll: false - }).then(notes => { - this.notes = notes; - this.fetching = false; - }); - }, - - onNote(note) { - if (note.replyId != null) return; - if (note.renoteId != null) return; - if (note.poll != null) return; - if (note.localOnly) return; - - this.notes.unshift(note); - }, - } -}); -</script> - -<style lang="stylus" scoped> -.ldzpakcixzickvggyixyrhqwjaefknon-enter -.ldzpakcixzickvggyixyrhqwjaefknon-leave-to - opacity 0 - transform translateY(-30px) - -.mk-welcome-timeline - background var(--face) - - > div - > * - transition transform .3s ease, opacity .3s ease - - > div - padding 16px - overflow-wrap break-word - font-size .9em - color var(--noteText) - border-bottom 1px solid var(--faceDivider) - - &:after - content "" - display block - clear both - - > .avatar - display block - float left - position -webkit-sticky - position sticky - top 16px - width 42px - height 42px - border-radius 6px - - > .body - float right - width calc(100% - 42px) - padding-left 12px - - > header - display flex - align-items center - margin-bottom 4px - white-space nowrap - - > .name - display block - margin 0 .5em 0 0 - padding 0 - overflow hidden - font-weight bold - text-overflow ellipsis - color var(--noteHeaderName) - - > .username - margin 0 .5em 0 0 - color var(--noteHeaderAcct) - - > .info - margin-left auto - font-size 0.9em - - > .created-at - color var(--noteHeaderInfo) - - > .text - text-align left - -</style> diff --git a/src/client/app/common/views/deck/deck.column-core.vue b/src/client/app/common/views/deck/deck.column-core.vue deleted file mode 100644 index 974c58235d..0000000000 --- a/src/client/app/common/views/deck/deck.column-core.vue +++ /dev/null @@ -1,49 +0,0 @@ -<template> -<x-widgets-column v-if="column.type == 'widgets'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> -<x-notifications-column v-else-if="column.type == 'notifications'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> -<x-tl-column v-else-if="column.type == 'home'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> -<x-tl-column v-else-if="column.type == 'local'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> -<x-tl-column v-else-if="column.type == 'hybrid'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> -<x-tl-column v-else-if="column.type == 'global'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> -<x-tl-column v-else-if="column.type == 'list'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> -<x-tl-column v-else-if="column.type == 'hashtag'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> -<x-mentions-column v-else-if="column.type == 'mentions'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> -<x-direct-column v-else-if="column.type == 'direct'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import XTlColumn from './deck.tl-column.vue'; -import XNotificationsColumn from './deck.notifications-column.vue'; -import XWidgetsColumn from './deck.widgets-column.vue'; -import XMentionsColumn from './deck.mentions-column.vue'; -import XDirectColumn from './deck.direct-column.vue'; - -export default Vue.extend({ - components: { - XTlColumn, - XNotificationsColumn, - XWidgetsColumn, - XMentionsColumn, - XDirectColumn - }, - - props: { - column: { - type: Object, - required: true - }, - isStacked: { - type: Boolean, - required: false, - default: false - } - }, - - methods: { - focus() { - this.$children[0].focus(); - } - } -}); -</script> diff --git a/src/client/app/common/views/deck/deck.column-template.vue b/src/client/app/common/views/deck/deck.column-template.vue deleted file mode 100644 index 5923285162..0000000000 --- a/src/client/app/common/views/deck/deck.column-template.vue +++ /dev/null @@ -1,45 +0,0 @@ -<template> -<x-column> - <template #header> - <fa v-if="icon" :icon="icon"/>{{ title }} - </template> - - <div> - <component :is="component" @init="init" v-bind="$attrs"/> - </div> -</x-column> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import XColumn from './deck.column.vue'; - -export default Vue.extend({ - components: { - XColumn, - }, - - props: { - component: { - required: true - } - }, - - data() { - return { - title: null, - icon: null, - }; - }, - - mounted() { - }, - - methods: { - init(v) { - this.title = v.title; - this.icon = v.icon; - } - } -}); -</script> diff --git a/src/client/app/common/views/deck/deck.column.vue b/src/client/app/common/views/deck/deck.column.vue deleted file mode 100644 index ac69a97df5..0000000000 --- a/src/client/app/common/views/deck/deck.column.vue +++ /dev/null @@ -1,444 +0,0 @@ -<template> -<div class="dnpfarvgbnfmyzbdquhhzyxcmstpdqzs" :class="{ naked, narrow, active, isStacked, draghover, dragging, dropready, isMobile: $root.isMobile, shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }" - @dragover.prevent.stop="onDragover" - @dragleave="onDragleave" - @drop.prevent.stop="onDrop" - v-hotkey="keymap"> - <header :class="{ indicate: count > 0 }" - draggable="true" - @click="goTop" - @dragstart="onDragstart" - @dragend="onDragend" - @contextmenu.prevent.stop="onContextmenu"> - <button class="toggleActive" @click="toggleActive" v-if="isStacked"> - <template v-if="active"><fa icon="angle-up"/></template> - <template v-else><fa icon="angle-down"/></template> - </button> - <span class="header"><slot name="header"></slot></span> - <span class="count" v-if="count > 0">({{ count }})</span> - <button v-if="!isTemporaryColumn" class="menu" ref="menu" @click.stop="showMenu"><fa icon="caret-down"/></button> - <button v-else class="close" @click.stop="close"><fa icon="times"/></button> - </header> - <div ref="body" v-show="active"> - <slot></slot> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import Menu from '../../../common/views/components/menu.vue'; -import { countIf } from '../../../../../prelude/array'; -import { faArrowUp, faArrowDown } from '@fortawesome/free-solid-svg-icons'; -import { faWindowMaximize } from '@fortawesome/free-regular-svg-icons'; - -export default Vue.extend({ - i18n: i18n('deck'), - props: { - column: { - type: Object, - required: false, - default: null - }, - isStacked: { - type: Boolean, - required: false, - default: false - }, - name: { - type: String, - required: false - }, - menu: { - type: Array, - required: false, - default: null - }, - naked: { - type: Boolean, - required: false, - default: false - }, - narrow: { - type: Boolean, - required: false, - default: false - } - }, - - data() { - return { - count: 0, - active: true, - dragging: false, - draghover: false, - dropready: false, - faArrowUp, faArrowDown - }; - }, - - computed: { - isTemporaryColumn(): boolean { - return this.column == null; - }, - - keymap(): any { - return { - 'shift+up': () => this.$parent.$emit('parentFocus', 'up'), - 'shift+down': () => this.$parent.$emit('parentFocus', 'down'), - 'shift+left': () => this.$parent.$emit('parentFocus', 'left'), - 'shift+right': () => this.$parent.$emit('parentFocus', 'right'), - }; - } - }, - - inject: { - getColumnVm: { from: 'getColumnVm' } - }, - - watch: { - active(v) { - if (v && this.isScrollTop()) { - this.$emit('top'); - } - }, - dragging(v) { - this.$root.$emit(v ? 'deck.column.dragStart' : 'deck.column.dragEnd'); - } - }, - - provide() { - return { - column: this, - isScrollTop: this.isScrollTop, - count: v => this.count = v, - inNakedDeckColumn: !this.naked - }; - }, - - mounted() { - this.$refs.body.addEventListener('scroll', this.onScroll, { passive: true }); - - if (!this.isTemporaryColumn) { - this.$root.$on('deck.column.dragStart', this.onOtherDragStart); - this.$root.$on('deck.column.dragEnd', this.onOtherDragEnd); - } - }, - - beforeDestroy() { - this.$refs.body.removeEventListener('scroll', this.onScroll); - - if (!this.isTemporaryColumn) { - this.$root.$off('deck.column.dragStart', this.onOtherDragStart); - this.$root.$off('deck.column.dragEnd', this.onOtherDragEnd); - } - }, - - methods: { - onOtherDragStart() { - this.dropready = true; - }, - - onOtherDragEnd() { - this.dropready = false; - }, - - toggleActive() { - if (!this.isStacked) return; - const deck = this.$store.state.device.deckProfile ? this.$store.state.settings.deckProfiles[this.$store.state.device.deckProfile] : this.$store.state.device.deck; - const vms = deck.layout.find(ids => ids.indexOf(this.column.id) != -1).map(id => this.getColumnVm(id)); - if (this.active && countIf(vm => vm.$el.classList.contains('active'), vms) == 1) return; - this.active = !this.active; - }, - - isScrollTop() { - return this.active && this.$refs.body.scrollTop == 0; - }, - - onScroll() { - if (this.isScrollTop()) { - this.$emit('top'); - } - - if (this.$store.state.settings.fetchOnScroll) { - const current = this.$refs.body.scrollTop + this.$refs.body.clientHeight; - if (current > this.$refs.body.scrollHeight - 1) this.$emit('bottom'); - } - }, - - getMenu() { - const items = [{ - icon: 'pencil-alt', - text: this.$t('rename'), - action: () => { - this.$root.dialog({ - title: this.$t('rename'), - input: { - default: this.name, - allowEmpty: false - } - }).then(({ canceled, result: name }) => { - if (canceled) return; - this.$store.commit('renameDeckColumn', { id: this.column.id, name }); - }); - } - }, null, { - icon: 'arrow-left', - text: this.$t('swap-left'), - action: () => { - this.$store.commit('swapLeftDeckColumn', this.column.id); - } - }, { - icon: 'arrow-right', - text: this.$t('swap-right'), - action: () => { - this.$store.commit('swapRightDeckColumn', this.column.id); - } - }, this.isStacked ? { - icon: faArrowUp, - text: this.$t('swap-up'), - action: () => { - this.$store.commit('swapUpDeckColumn', this.column.id); - } - } : undefined, this.isStacked ? { - icon: faArrowDown, - text: this.$t('swap-down'), - action: () => { - this.$store.commit('swapDownDeckColumn', this.column.id); - } - } : undefined, null, { - icon: ['far', 'window-restore'], - text: this.$t('stack-left'), - action: () => { - this.$store.commit('stackLeftDeckColumn', this.column.id); - } - }, this.isStacked ? { - icon: faWindowMaximize, - text: this.$t('pop-right'), - action: () => { - this.$store.commit('popRightDeckColumn', this.column.id); - } - } : undefined, null, { - icon: ['far', 'trash-alt'], - text: this.$t('remove'), - action: () => { - this.$store.commit('removeDeckColumn', this.column.id); - } - }]; - - if (this.menu) { - items.unshift(null); - for (const i of this.menu.reverse()) { - items.unshift(i); - } - } - - return items; - }, - - onContextmenu(e) { - if (this.isTemporaryColumn) return; - this.$contextmenu(e, this.getMenu()); - }, - - showMenu() { - this.$root.new(Menu, { - source: this.$refs.menu, - items: this.getMenu() - }); - }, - - close() { - this.$router.push('/'); - }, - - goTop() { - this.$refs.body.scrollTo({ - top: 0, - behavior: 'smooth' - }); - }, - - onDragstart(e) { - // テンポラリカラムはドラッグさせない - if (this.isTemporaryColumn) { - e.preventDefault(); - return; - } - - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('mk-deck-column', this.column.id); - this.dragging = true; - }, - - onDragend(e) { - this.dragging = false; - }, - - onDragover(e) { - // テンポラリカラムにはドロップさせない - if (this.isTemporaryColumn) { - e.dataTransfer.dropEffect = 'none'; - return; - } - - // 自分自身がドラッグされている場合 - if (this.dragging) { - // 自分自身にはドロップさせない - e.dataTransfer.dropEffect = 'none'; - return; - } - - const isDeckColumn = e.dataTransfer.types[0] == 'mk-deck-column'; - - e.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none'; - - if (!this.dragging && isDeckColumn) this.draghover = true; - }, - - onDragleave() { - this.draghover = false; - }, - - onDrop(e) { - this.draghover = false; - this.$root.$emit('deck.column.dragEnd'); - - const id = e.dataTransfer.getData('mk-deck-column'); - if (id != null && id != '') { - this.$store.commit('swapDeckColumn', { - a: this.column.id, - b: id - }); - } - } - } -}); -</script> - -<style lang="stylus" scoped> -.dnpfarvgbnfmyzbdquhhzyxcmstpdqzs - $header-height = 42px - - height 100% - background var(--face) - overflow hidden - - &.round - border-radius 6px - - &.shadow - box-shadow 0 3px 8px rgba(0, 0, 0, 0.2) - - &.draghover - box-shadow 0 0 0 2px var(--primaryAlpha08) - - &:after - content "" - display block - position absolute - z-index 1000 - top 0 - left 0 - width 100% - height 100% - background var(--primaryAlpha02) - - &.dragging - box-shadow 0 0 0 2px var(--primaryAlpha04) - - &.dropready - * - pointer-events none - - &:not(.active) - flex-basis $header-height - min-height $header-height - - &:not(.isStacked).narrow - width 285px - min-width 285px - flex-grow 0 !important - - &.naked - background var(--deckAcrylicColumnBg) - - > header - background transparent - box-shadow none - - > button - color var(--text) - - &.isMobile - > header - box-shadow none - - > header - display flex - z-index 2 - line-height $header-height - padding 0 16px - font-size 14px - color var(--faceHeaderText) - background var(--faceHeader) - box-shadow 0 var(--lineWidth) rgba(#000, 0.15) - cursor pointer - - &, * - user-select none - - *:not(button) - pointer-events none - - &.indicate - box-shadow 0 3px 0 0 var(--primary) - - > .header - display inline-block - align-items center - overflow hidden - text-overflow ellipsis - white-space nowrap - - [data-icon] - margin-right 8px - - > .count - margin-left 4px - opacity 0.5 - - > span:only-of-type - width 100% - - > .toggleActive - > .menu - > .close - padding 0 - width $header-height - line-height $header-height - font-size 16px - color var(--faceTextButton) - - &:hover - color var(--faceTextButtonHover) - - &:active - color var(--faceTextButtonActive) - - > .toggleActive - margin-left -16px - - > .menu - > .close - margin-left auto - margin-right -16px - - > div - height "calc(100% - %s)" % $header-height - overflow auto - overflow-x hidden - -webkit-overflow-scrolling touch - -</style> diff --git a/src/client/app/common/views/deck/deck.direct-column.vue b/src/client/app/common/views/deck/deck.direct-column.vue deleted file mode 100644 index 66d34520af..0000000000 --- a/src/client/app/common/views/deck/deck.direct-column.vue +++ /dev/null @@ -1,79 +0,0 @@ -<template> -<x-column :name="name" :column="column" :is-stacked="isStacked"> - <template #header><fa :icon="['far', 'envelope']"/>{{ name }}</template> - - <x-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/> -</x-column> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XColumn from './deck.column.vue'; -import XNotes from './deck.notes.vue'; - -export default Vue.extend({ - i18n: i18n(), - - components: { - XColumn, - XNotes - }, - - props: { - column: { - type: Object, - required: true - }, - isStacked: { - type: Boolean, - required: true - } - }, - - data() { - return { - connection: null, - pagination: { - endpoint: 'notes/mentions', - limit: 10, - params: { - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes, - visibility: 'specified' - } - } - }; - }, - - computed: { - name(): string { - if (this.column.name) return this.column.name; - return this.$t('@deck.direct'); - } - }, - - mounted() { - this.connection = this.$root.stream.useSharedConnection('main'); - this.connection.on('mention', this.onNote); - }, - - beforeDestroy() { - this.connection.dispose(); - }, - - methods: { - onNote(note) { - // Prepend a note - if (note.visibility == 'specified') { - (this.$refs.timeline as any).prepend(note); - } - }, - - focus() { - this.$refs.timeline.focus(); - } - } -}); -</script> diff --git a/src/client/app/common/views/deck/deck.hashtag-column.vue b/src/client/app/common/views/deck/deck.hashtag-column.vue deleted file mode 100644 index 0d719c2199..0000000000 --- a/src/client/app/common/views/deck/deck.hashtag-column.vue +++ /dev/null @@ -1,122 +0,0 @@ -<template> -<x-column> - <template #header> - <fa icon="hashtag"/><span>{{ tag }}</span> - </template> - - <div class="xroyrflcmhhtmlwmyiwpfqiirqokfueb"> - <div ref="chart" class="chart"></div> - <x-hashtag-tl :tag-tl="tagTl" class="tl" :key="JSON.stringify(tagTl)"/> - </div> -</x-column> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import XColumn from './deck.column.vue'; -import XHashtagTl from './deck.hashtag-tl.vue'; -import ApexCharts from 'apexcharts'; - -export default Vue.extend({ - components: { - XColumn, - XHashtagTl - }, - - computed: { - tag(): string { - return this.$route.params.tag; - }, - - tagTl(): any { - return { - query: [[this.tag]] - }; - } - }, - - watch: { - $route: 'fetch' - }, - - created() { - this.fetch(); - }, - - methods: { - fetch() { - this.$root.api('charts/hashtag', { - tag: this.tag, - span: 'hour', - limit: 24 - }).then(stats => { - const local = []; - const remote = []; - - const now = new Date(); - const y = now.getFullYear(); - const m = now.getMonth(); - const d = now.getDate(); - const h = now.getHours(); - - for (let i = 0; i < 24; i++) { - const x = new Date(y, m, d, h - i); - local.push([x, stats.local.count[i]]); - remote.push([x, stats.remote.count[i]]); - } - - const chart = new ApexCharts(this.$refs.chart, { - chart: { - type: 'area', - height: 70, - sparkline: { - enabled: true - }, - }, - dataLabels: { - enabled: false - }, - grid: { - clipMarkers: false, - padding: { - top: 16, - right: 16, - bottom: 16, - left: 16 - } - }, - stroke: { - curve: 'straight', - width: 2 - }, - series: [{ - name: 'Local', - data: local - }, { - name: 'Remote', - data: remote - }], - xaxis: { - type: 'datetime', - } - }); - - chart.render(); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.xroyrflcmhhtmlwmyiwpfqiirqokfueb - background var(--deckColumnBg) - - > .chart - margin-bottom 16px - background var(--face) - - > .tl - background var(--face) - -</style> diff --git a/src/client/app/common/views/deck/deck.hashtag-tl.vue b/src/client/app/common/views/deck/deck.hashtag-tl.vue deleted file mode 100644 index 94d2efc430..0000000000 --- a/src/client/app/common/views/deck/deck.hashtag-tl.vue +++ /dev/null @@ -1,73 +0,0 @@ -<template> -<x-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import XNotes from './deck.notes.vue'; - -export default Vue.extend({ - components: { - XNotes - }, - - props: { - tagTl: { - type: Object, - required: true - }, - mediaOnly: { - type: Boolean, - required: false, - default: false - } - }, - - data() { - return { - connection: null, - pagination: { - endpoint: 'notes/search-by-tag', - limit: 10, - params: init => ({ - untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), - withFiles: this.mediaOnly, - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes, - query: this.tagTl.query - }) - } - }; - }, - - watch: { - mediaOnly() { - this.$refs.timeline.reload(); - } - }, - - mounted() { - if (this.connection) this.connection.close(); - this.connection = this.$root.stream.connectToChannel('hashtag', { - q: this.tagTl.query - }); - this.connection.on('note', this.onNote); - }, - - beforeDestroy() { - this.connection.dispose(); - }, - - methods: { - onNote(note) { - if (this.mediaOnly && note.files.length == 0) return; - (this.$refs.timeline as any).prepend(note); - }, - - focus() { - this.$refs.timeline.focus(); - } - } -}); -</script> diff --git a/src/client/app/common/views/deck/deck.list-tl.vue b/src/client/app/common/views/deck/deck.list-tl.vue deleted file mode 100644 index 26d6ea9d58..0000000000 --- a/src/client/app/common/views/deck/deck.list-tl.vue +++ /dev/null @@ -1,83 +0,0 @@ -<template> -<x-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import XNotes from './deck.notes.vue'; - -export default Vue.extend({ - components: { - XNotes - }, - - props: { - list: { - type: Object, - required: true - }, - mediaOnly: { - type: Boolean, - required: false, - default: false - } - }, - - data() { - return { - connection: null, - pagination: { - endpoint: 'notes/user-list-timeline', - limit: 10, - params: init => ({ - listId: this.list.id, - untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), - withFiles: this.mediaOnly, - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes - }) - } - }; - }, - - watch: { - mediaOnly() { - this.$refs.timeline.reload(); - } - }, - - mounted() { - if (this.connection) this.connection.dispose(); - this.connection = this.$root.stream.connectToChannel('userList', { - listId: this.list.id - }); - this.connection.on('note', this.onNote); - this.connection.on('userAdded', this.onUserAdded); - this.connection.on('userRemoved', this.onUserRemoved); - }, - - beforeDestroy() { - this.connection.dispose(); - }, - - methods: { - onNote(note) { - if (this.mediaOnly && note.files.length == 0) return; - (this.$refs.timeline as any).prepend(note); - }, - - onUserAdded() { - this.$refs.timeline.reload(); - }, - - onUserRemoved() { - this.$refs.timeline.reload(); - }, - - focus() { - this.$refs.timeline.focus(); - } - } -}); -</script> diff --git a/src/client/app/common/views/deck/deck.mentions-column.vue b/src/client/app/common/views/deck/deck.mentions-column.vue deleted file mode 100644 index 12d7b2a16b..0000000000 --- a/src/client/app/common/views/deck/deck.mentions-column.vue +++ /dev/null @@ -1,76 +0,0 @@ -<template> -<x-column :name="name" :column="column" :is-stacked="isStacked"> - <template #header><fa icon="at"/>{{ name }}</template> - - <x-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/> -</x-column> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XColumn from './deck.column.vue'; -import XNotes from './deck.notes.vue'; - -export default Vue.extend({ - i18n: i18n(), - - components: { - XColumn, - XNotes - }, - - props: { - column: { - type: Object, - required: true - }, - isStacked: { - type: Boolean, - required: true - } - }, - - data() { - return { - connection: null, - pagination: { - endpoint: 'notes/mentions', - limit: 10, - params: init => ({ - untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes - }) - } - }; - }, - - computed: { - name(): string { - if (this.column.name) return this.column.name; - return this.$t('@deck.mentions'); - } - }, - - mounted() { - this.connection = this.$root.stream.useSharedConnection('main'); - this.connection.on('mention', this.onNote); - }, - - beforeDestroy() { - this.connection.dispose(); - }, - - methods: { - onNote(note) { - (this.$refs.timeline as any).prepend(note); - }, - - focus() { - this.$refs.timeline.focus(); - } - } -}); -</script> diff --git a/src/client/app/common/views/deck/deck.note-column.vue b/src/client/app/common/views/deck/deck.note-column.vue deleted file mode 100644 index bcc887e2fd..0000000000 --- a/src/client/app/common/views/deck/deck.note-column.vue +++ /dev/null @@ -1,73 +0,0 @@ -<template> -<x-column> - <template #header> - <fa :icon="['far', 'comment-alt']"/><mk-user-name :user="note.user" v-if="note"/> - </template> - - <div class="rvtscbadixhhbsczoorqoaygovdeecsx" v-if="note"> - <div class="is-remote" v-if="note.user.host != null"> - <details> - <summary><fa icon="exclamation-triangle"/> {{ $t('@.is-remote-post') }}</summary> - <a :href="note.url || note.uri" rel="nofollow noopener" target="_blank">{{ $t('@.view-on-remote') }}</a> - </details> - </div> - <mk-note :note="note" :detail="true" :key="note.id"/> - </div> -</x-column> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XColumn from './deck.column.vue'; - -export default Vue.extend({ - i18n: i18n(), - components: { - XColumn, - }, - - data() { - return { - note: null, - fetching: true - }; - }, - - watch: { - $route: 'fetch' - }, - - created() { - this.fetch(); - }, - - methods: { - fetch() { - this.fetching = true; - - this.$root.api('notes/show', { - noteId: this.$route.params.note - }).then(note => { - this.note = note; - this.fetching = false; - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.rvtscbadixhhbsczoorqoaygovdeecsx - > .is-remote - padding 8px 16px - font-size 12px - - &.is-remote - color var(--remoteInfoFg) - background var(--remoteInfoBg) - - > a - font-weight bold - -</style> diff --git a/src/client/app/common/views/deck/deck.notes.vue b/src/client/app/common/views/deck/deck.notes.vue deleted file mode 100644 index 5081d1f998..0000000000 --- a/src/client/app/common/views/deck/deck.notes.vue +++ /dev/null @@ -1,168 +0,0 @@ -<template> -<div class="eamppglmnmimdhrlzhplwpvyeaqmmhxu"> - <div class="empty" v-if="empty">{{ $t('@.no-notes') }}</div> - - <mk-error v-if="error" @retry="init()"/> - - <div class="placeholder" v-if="fetching"> - <template v-for="i in 10"> - <mk-note-skeleton :key="i"/> - </template> - </div> - - <!-- トランジションを有効にするとなぜかメモリリークする --> - <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="transition notes" ref="notes" tag="div"> - <template v-for="(note, i) in _notes"> - <mk-note - :note="note" - :key="note.id" - :compact="true" - /> - <p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date"> - <span><fa icon="angle-up"/>{{ note._datetext }}</span> - <span><fa icon="angle-down"/>{{ _notes[i + 1]._datetext }}</span> - </p> - </template> - </component> - - <footer v-if="more"> - <button @click="fetchMore()" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> - <template v-if="!moreFetching">{{ $t('@.load-more') }}</template> - <template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template> - </button> - </footer> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import shouldMuteNote from '../../../common/scripts/should-mute-note'; -import paging from '../../../common/scripts/paging'; - -export default Vue.extend({ - i18n: i18n(), - - inject: ['column', 'isScrollTop', 'count'], - - mixins: [ - paging({ - limit: 20, - - onQueueChanged: (self, q) => { - self.count(q.length); - }, - - onPrepend: (self, note, silent) => { - // 弾く - if (shouldMuteNote(self.$store.state.i, self.$store.state.settings, note)) return false; - - // タブが非表示またはスクロール位置が最上部ではないならタイトルで通知 - if (document.hidden || !self.isScrollTop()) { - self.$store.commit('pushBehindNote', note); - } - } - }), - ], - - props: { - pagination: { - required: true - }, - extract: { - required: false - } - }, - - computed: { - notes() { - return this.extract ? this.extract(this.items) : this.items; - }, - - _notes(): any[] { - return (this.notes as any).map(note => { - const date = new Date(note.createdAt).getDate(); - const month = new Date(note.createdAt).getMonth() + 1; - note._date = date; - note._datetext = this.$t('@.month-and-day').replace('{month}', month.toString()).replace('{day}', date.toString()); - return note; - }); - } - }, - - created() { - this.column.$on('top', this.onTop); - this.column.$on('bottom', this.onBottom); - this.init(); - }, - - beforeDestroy() { - this.column.$off('top', this.onTop); - this.column.$off('bottom', this.onBottom); - }, - - methods: { - focus() { - (this.$refs.notes as any).children[0].focus ? (this.$refs.notes as any).children[0].focus() : (this.$refs.notes as any).$el.children[0].focus(); - }, - } -}); -</script> - -<style lang="stylus" scoped> -.eamppglmnmimdhrlzhplwpvyeaqmmhxu - .transition - .mk-notes-enter - .mk-notes-leave-to - opacity 0 - transform translateY(-30px) - - > * - transition transform .3s ease, opacity .3s ease - - > .empty - padding 16px - text-align center - color var(--text) - - > .placeholder - padding 16px - opacity 0.3 - - > .notes - > .date - display block - margin 0 - line-height 28px - font-size 12px - text-align center - color var(--dateDividerFg) - background var(--dateDividerBg) - border-bottom solid var(--lineWidth) var(--faceDivider) - - span - margin 0 16px - - [data-icon] - margin-right 8px - - > footer - > button - display block - margin 0 - padding 16px - width 100% - text-align center - color #ccc - background var(--face) - border-top solid var(--lineWidth) var(--faceDivider) - border-bottom-left-radius 6px - border-bottom-right-radius 6px - - &:hover - box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05) - - &:active - box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1) - -</style> diff --git a/src/client/app/common/views/deck/deck.notification.vue b/src/client/app/common/views/deck/deck.notification.vue deleted file mode 100644 index da122ff4db..0000000000 --- a/src/client/app/common/views/deck/deck.notification.vue +++ /dev/null @@ -1,186 +0,0 @@ -<template> -<div class="dsfykdcjpuwfvpefwufddclpjhzktmpw"> - <div class="notification reaction" v-if="notification.type == 'reaction'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div> - <header> - <mk-reaction-icon :reaction="notification.reaction" class="icon"/> - <router-link :to="notification.user | userPage" class="name"> - <mk-user-name :user="notification.user"/> - </router-link> - <mk-time :time="notification.createdAt"/> - </header> - <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> - <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :custom-emojis="notification.note.emojis"/> - <fa icon="quote-right"/> - </router-link> - </div> - </div> - - <div class="notification renote" v-if="notification.type == 'renote'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div> - <header> - <fa icon="retweet" class="icon"/> - <router-link :to="notification.user | userPage" class="name"> - <mk-user-name :user="notification.user"/> - </router-link> - <mk-time :time="notification.createdAt"/> - </header> - <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note.renote)"> - <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="true" :custom-emojis="notification.note.renote.emojis"/> - <fa icon="quote-right"/> - </router-link> - </div> - </div> - - <div class="notification follow" v-if="notification.type == 'follow'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div> - <header> - <fa icon="user-plus" class="icon"/> - <router-link :to="notification.user | userPage" class="name"> - <mk-user-name :user="notification.user"/> - </router-link> - <mk-time :time="notification.createdAt"/> - </header> - </div> - </div> - - <div class="notification followRequest" v-if="notification.type == 'receiveFollowRequest'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div> - <header> - <fa icon="user-clock" class="icon"/> - <router-link :to="notification.user | userPage" class="name"> - <mk-user-name :user="notification.user"/> - </router-link> - <mk-time :time="notification.createdAt"/> - </header> - </div> - </div> - - <div class="notification pollVote" v-if="notification.type == 'pollVote'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div> - <header> - <fa icon="chart-pie" class="icon"/> - <router-link :to="notification.user | userPage" class="name"> - <mk-user-name :user="notification.user"/> - </router-link> - <mk-time :time="notification.createdAt"/> - </header> - <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> - <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :custom-emojis="notification.note.emojis"/> - <fa icon="quote-right"/> - </router-link> - </div> - </div> - - <template v-if="notification.type == 'quote'"> - <mk-note :note="notification.note"/> - </template> - - <template v-if="notification.type == 'reply'"> - <mk-note :note="notification.note"/> - </template> - - <template v-if="notification.type == 'mention'"> - <mk-note :note="notification.note"/> - </template> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import getNoteSummary from '../../../../../misc/get-note-summary'; - -export default Vue.extend({ - props: ['notification'], - data() { - return { - getNoteSummary - }; - }, -}); -</script> - -<style lang="stylus" scoped> -.dsfykdcjpuwfvpefwufddclpjhzktmpw - > .notification - padding 16px - font-size 12px - overflow-wrap break-word - - &:after - content "" - display block - clear both - - > .avatar - display block - float left - width 36px - height 36px - border-radius 6px - - > div - float right - width calc(100% - 36px) - padding-left 8px - - > header - display flex - align-items baseline - white-space nowrap - - > .icon - margin-right 4px - - > .name - overflow hidden - text-overflow ellipsis - - > .mk-time - margin-left auto - color var(--noteHeaderInfo) - font-size 0.9em - - > .note-preview - color var(--noteText) - - > .note-ref - color var(--noteText) - display inline-block - width: 100% - overflow hidden - white-space nowrap - text-overflow ellipsis - - [data-icon] - font-size 1em - font-weight normal - font-style normal - display inline-block - margin-right 3px - - &.reaction - > div > header - align-items normal - - &.renote - > div > header [data-icon] - color #77B255 - - &.follow - > div > header [data-icon] - color #53c7ce - - &.receiveFollowRequest - > div > header [data-icon] - color #888 - -</style> diff --git a/src/client/app/common/views/deck/deck.notifications-column.vue b/src/client/app/common/views/deck/deck.notifications-column.vue deleted file mode 100644 index b4361b054a..0000000000 --- a/src/client/app/common/views/deck/deck.notifications-column.vue +++ /dev/null @@ -1,75 +0,0 @@ -<template> -<x-column :name="name" :column="column" :is-stacked="isStacked" :menu="menu"> - <template #header><fa :icon="['far', 'bell']"/>{{ name }}</template> - - <x-notifications :type="column.notificationType === 'all' ? null : column.notificationType"/> -</x-column> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XColumn from './deck.column.vue'; -import XNotifications from './deck.notifications.vue'; - -export default Vue.extend({ - i18n: i18n(), - components: { - XColumn, - XNotifications - }, - - props: { - column: { - type: Object, - required: true - }, - isStacked: { - type: Boolean, - required: true - } - }, - - data() { - return { - menu: null, - } - }, - - computed: { - name(): string { - if (this.column.name) return this.column.name; - return this.$t('@deck.notifications'); - } - }, - - created() { - if (this.column.notificationType == null) { - this.column.notificationType = 'all'; - this.$store.commit('updateDeckColumn', this.column); - } - - this.menu = [{ - icon: 'cog', - text: this.$t('@.notification-type'), - action: () => { - this.$root.dialog({ - title: this.$t('@.notification-type'), - type: null, - select: { - items: ['all', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest'].map(x => ({ - value: x, text: this.$t('@.notification-types.' + x) - })) - default: this.column.notificationType, - }, - showCancelButton: true - }).then(({ canceled, result: type }) => { - if (canceled) return; - this.column.notificationType = type; - this.$store.commit('updateDeckColumn', this.column); - }); - } - }]; - }, -}); -</script> diff --git a/src/client/app/common/views/deck/deck.notifications.vue b/src/client/app/common/views/deck/deck.notifications.vue deleted file mode 100644 index aed2af64e9..0000000000 --- a/src/client/app/common/views/deck/deck.notifications.vue +++ /dev/null @@ -1,177 +0,0 @@ -<template> -<div class="oxynyeqmfvracxnglgulyqfgqxnxmehl"> - <div class="placeholder" v-if="fetching"> - <template v-for="i in 10"> - <mk-note-skeleton :key="i"/> - </template> - </div> - - <!-- トランジションを有効にするとなぜかメモリリークする --> - <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications" tag="div"> - <template v-for="(notification, i) in _notifications"> - <x-notification class="notification" :notification="notification" :key="notification.id"/> - <p class="date" v-if="i != items.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'"> - <span><fa icon="angle-up"/>{{ notification._datetext }}</span> - <span><fa icon="angle-down"/>{{ _notifications[i + 1]._datetext }}</span> - </p> - </template> - </component> - <button class="more" :class="{ fetching: moreFetching }" v-if="more" @click="fetchMore" :disabled="moreFetching"> - <template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template>{{ moreFetching ? this.$t('@.loading') : this.$t('@.load-more') }} - </button> - <p class="empty" v-if="empty">{{ $t('empty') }}</p> - <mk-error v-if="error" @retry="init()"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XNotification from './deck.notification.vue'; -import paging from '../../../common/scripts/paging'; - -export default Vue.extend({ - i18n: i18n(), - - components: { - XNotification - }, - - inject: ['column', 'isScrollTop', 'count'], - - mixins: [ - paging({ - onQueueChanged: (self, q) => { - self.count(q.length); - }, - }), - ], - - props: { - type: { - type: String, - required: false - } - }, - - data() { - return { - connection: null, - pagination: { - endpoint: 'i/notifications', - limit: 20, - params: () => ({ - includeTypes: this.type ? [this.type] : undefined - }) - } - }; - }, - - computed: { - _notifications(): any[] { - return (this.items as any).map(notification => { - const date = new Date(notification.createdAt).getDate(); - const month = new Date(notification.createdAt).getMonth() + 1; - notification._date = date; - notification._datetext = this.$t('@.month-and-day').replace('{month}', month.toString()).replace('{day}', date.toString()); - return notification; - }); - } - }, - - watch: { - type() { - this.reload(); - } - }, - - mounted() { - this.connection = this.$root.stream.useSharedConnection('main'); - this.connection.on('notification', this.onNotification); - - this.column.$on('top', this.onTop); - this.column.$on('bottom', this.onBottom); - }, - - beforeDestroy() { - this.connection.dispose(); - - this.column.$off('top', this.onTop); - this.column.$off('bottom', this.onBottom); - }, - - methods: { - onNotification(notification) { - // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない - this.$root.stream.send('readNotification', { - id: notification.id - }); - - this.prepend(notification); - }, - } -}); -</script> - -<style lang="stylus" scoped> -.oxynyeqmfvracxnglgulyqfgqxnxmehl - .transition - .mk-notifications-enter - .mk-notifications-leave-to - opacity 0 - transform translateY(-30px) - - > * - transition transform .3s ease, opacity .3s ease - - > .placeholder - padding 16px - opacity 0.3 - - > .notifications - - > .notification:not(:last-child) - border-bottom solid var(--lineWidth) var(--faceDivider) - - > .date - display block - margin 0 - line-height 28px - text-align center - font-size 12px - color var(--dateDividerFg) - background var(--dateDividerBg) - border-bottom solid var(--lineWidth) var(--faceDivider) - - span - margin 0 16px - - [data-icon] - margin-right 8px - - > .more - display block - width 100% - padding 16px - color var(--text) - border-top solid var(--lineWidth) rgba(#000, 0.05) - - &:hover - background rgba(#000, 0.025) - - &:active - background rgba(#000, 0.05) - - &.fetching - cursor wait - - > [data-icon] - margin-right 4px - - > .empty - margin 0 - padding 16px - text-align center - color var(--text) - -</style> diff --git a/src/client/app/common/views/deck/deck.page-column.vue b/src/client/app/common/views/deck/deck.page-column.vue deleted file mode 100644 index 0ef391a51d..0000000000 --- a/src/client/app/common/views/deck/deck.page-column.vue +++ /dev/null @@ -1,69 +0,0 @@ -<template> -<x-column> - <template #header> - <fa :icon="faStickyNote"/>{{ page ? page.name : '' }} - </template> - - <div v-if="page"> - <x-page :page="page" :key="page.id"/> - </div> -</x-column> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; -import i18n from '../../../i18n'; -import XColumn from './deck.column.vue'; -import XPage from '../../../common/views/components/page/page.vue'; - -export default Vue.extend({ - i18n: i18n(), - - components: { - XColumn, - XPage - }, - - props: { - pageName: { - type: String, - required: true - }, - username: { - type: String, - required: true - }, - }, - - data() { - return { - page: null, - faStickyNote - }; - }, - - watch: { - $route: 'fetch' - }, - - created() { - this.fetch(); - }, - - methods: { - fetch() { - this.$root.api('pages/show', { - name: this.pageName, - username: this.username, - }).then(page => { - this.page = page; - this.$emit('init', { - title: this.page.title, - icon: faStickyNote - }); - }); - } - } -}); -</script> diff --git a/src/client/app/common/views/deck/deck.search-column.vue b/src/client/app/common/views/deck/deck.search-column.vue deleted file mode 100644 index a2d1142fbe..0000000000 --- a/src/client/app/common/views/deck/deck.search-column.vue +++ /dev/null @@ -1,47 +0,0 @@ -<template> -<x-column> - <template #header> - <fa icon="search"/><span>{{ q }}</span> - </template> - - <div> - <x-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/> - </div> -</x-column> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import XColumn from './deck.column.vue'; -import XNotes from './deck.notes.vue'; -import { genSearchQuery } from '../../../common/scripts/gen-search-query'; - -export default Vue.extend({ - components: { - XColumn, - XNotes - }, - - data() { - return { - pagination: { - endpoint: 'notes/search', - limit: 20, - params: () => genSearchQuery(this, this.q) - } - }; - }, - - computed: { - q(): string { - return this.$route.query.q; - } - }, - - watch: { - $route() { - this.$refs.timeline.reload(); - } - }, -}); -</script> diff --git a/src/client/app/common/views/deck/deck.tl-column.vue b/src/client/app/common/views/deck/deck.tl-column.vue deleted file mode 100644 index cad140ed5f..0000000000 --- a/src/client/app/common/views/deck/deck.tl-column.vue +++ /dev/null @@ -1,101 +0,0 @@ -<template> -<x-column :menu="menu" :name="name" :column="column" :is-stacked="isStacked"> - <template #header> - <fa v-if="column.type == 'home'" icon="home"/> - <fa v-if="column.type == 'local'" :icon="['far', 'comments']"/> - <fa v-if="column.type == 'hybrid'" icon="share-alt"/> - <fa v-if="column.type == 'global'" icon="globe"/> - <fa v-if="column.type == 'list'" icon="list"/> - <fa v-if="column.type == 'hashtag'" icon="hashtag"/> - <span>{{ name }}</span> - </template> - - <div class="editor" style="padding:12px" v-if="edit"> - <ui-switch v-model="column.isMediaOnly" @change="onChangeSettings">{{ $t('is-media-only') }}</ui-switch> - </div> - - <x-list-tl v-if="column.type == 'list'" - :list="column.list" - :media-only="column.isMediaOnly" - ref="tl" - /> - <x-hashtag-tl v-else-if="column.type == 'hashtag'" - :tag-tl="$store.state.settings.tagTimelines.find(x => x.id == column.tagTlId)" - :media-only="column.isMediaOnly" - ref="tl" - /> - <x-tl v-else - :src="column.type" - :media-only="column.isMediaOnly" - ref="tl" - /> -</x-column> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XColumn from './deck.column.vue'; -import XTl from './deck.tl.vue'; -import XListTl from './deck.list-tl.vue'; -import XHashtagTl from './deck.hashtag-tl.vue'; - -export default Vue.extend({ - i18n: i18n('deck/deck.tl-column.vue'), - components: { - XColumn, - XTl, - XListTl, - XHashtagTl - }, - - props: { - column: { - type: Object, - required: true - }, - isStacked: { - type: Boolean, - required: true - } - }, - - data() { - return { - edit: false, - menu: [{ - icon: 'cog', - text: this.$t('edit'), - action: () => { - this.edit = !this.edit; - } - }] - } - }, - - computed: { - name(): string { - if (this.column.name) return this.column.name; - - switch (this.column.type) { - case 'home': return this.$t('@deck.home'); - case 'local': return this.$t('@deck.local'); - case 'hybrid': return this.$t('@deck.hybrid'); - case 'global': return this.$t('@deck.global'); - case 'list': return this.column.list.name; - case 'hashtag': return this.$store.state.settings.tagTimelines.find(x => x.id == this.column.tagTlId).title; - } - } - }, - - methods: { - onChangeSettings(v) { - this.$store.commit('updateDeckColumn', this.column); - }, - - focus() { - this.$refs.tl.focus(); - } - } -}); -</script> diff --git a/src/client/app/common/views/deck/deck.tl.vue b/src/client/app/common/views/deck/deck.tl.vue deleted file mode 100644 index e6c716070a..0000000000 --- a/src/client/app/common/views/deck/deck.tl.vue +++ /dev/null @@ -1,135 +0,0 @@ -<template> -<div class="iwaalbte" v-if="disabled"> - <p> - <fa :icon="faMinusCircle"/> - {{ $t('disabled-timeline.title') }} - </p> - <p class="desc">{{ $t('disabled-timeline.description') }}</p> -</div> -<x-notes v-else ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import XNotes from './deck.notes.vue'; -import { faMinusCircle } from '@fortawesome/free-solid-svg-icons'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('deck'), - - components: { - XNotes - }, - - props: { - src: { - type: String, - required: false, - default: 'home' - }, - mediaOnly: { - type: Boolean, - required: false, - default: false - } - }, - - data() { - return { - connection: null, - disabled: false, - faMinusCircle, - pagination: null - }; - }, - - computed: { - stream(): any { - switch (this.src) { - case 'home': return this.$root.stream.useSharedConnection('homeTimeline'); - case 'local': return this.$root.stream.useSharedConnection('localTimeline'); - case 'hybrid': return this.$root.stream.useSharedConnection('hybridTimeline'); - case 'global': return this.$root.stream.useSharedConnection('globalTimeline'); - } - }, - - endpoint(): string { - switch (this.src) { - case 'home': return 'notes/timeline'; - case 'local': return 'notes/local-timeline'; - case 'hybrid': return 'notes/hybrid-timeline'; - case 'global': return 'notes/global-timeline'; - } - }, - }, - - watch: { - mediaOnly() { - (this.$refs.timeline as any).reload(); - } - }, - - created() { - this.pagination = { - endpoint: this.endpoint, - limit: 10, - params: init => ({ - untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), - withFiles: this.mediaOnly, - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes - }) - }; - }, - - mounted() { - this.connection = this.stream; - - this.connection.on('note', this.onNote); - if (this.src == 'home') { - this.connection.on('follow', this.onChangeFollowing); - this.connection.on('unfollow', this.onChangeFollowing); - } - - this.$root.getMeta().then(meta => { - this.disabled = !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin && ( - meta.disableLocalTimeline && ['local', 'hybrid'].includes(this.src) || - meta.disableGlobalTimeline && ['global'].includes(this.src)); - }); - }, - - beforeDestroy() { - this.connection.dispose(); - }, - - methods: { - onNote(note) { - if (this.mediaOnly && note.files.length == 0) return; - (this.$refs.timeline as any).prepend(note); - }, - - onChangeFollowing() { - (this.$refs.timeline as any).reload(); - }, - - focus() { - (this.$refs.timeline as any).focus(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.iwaalbte - color var(--text) - text-align center - - > p - margin 16px - - &.desc - font-size 14px - -</style> diff --git a/src/client/app/common/views/deck/deck.user-column.home.vue b/src/client/app/common/views/deck/deck.user-column.home.vue deleted file mode 100644 index 9fb50a6672..0000000000 --- a/src/client/app/common/views/deck/deck.user-column.home.vue +++ /dev/null @@ -1,229 +0,0 @@ -<template> -<div> - <ui-container v-if="user.pinnedPage" :body-togglable="true"> - <template #header><fa icon="thumbtack"/> {{ $t('pinned-page') }}</template> - <div> - <x-page :page="user.pinnedPage" :key="user.pinnedPage.id" :show-title="!user.pinnedPage.hideTitleWhenPinned"/> - </div> - </ui-container> - <ui-container v-if="user.pinnedNotes && user.pinnedNotes.length > 0" :body-togglable="true"> - <template #header><fa icon="thumbtack"/> {{ $t('pinned-notes') }}</template> - <div> - <mk-note v-for="n in user.pinnedNotes" :key="n.id" :note="n"/> - </div> - </ui-container> - <ui-container v-if="images.length > 0" :body-togglable="true" - :expanded="$store.state.device.expandUsersPhotos" - @toggle="expanded => $store.commit('device/set', { key: 'expandUsersPhotos', value: expanded })"> - <template #header><fa :icon="['far', 'images']"/> {{ $t('images') }}</template> - <div class="sainvnaq"> - <router-link v-for="image in images" - :style="`background-image: url(${image.thumbnailUrl})`" - :key="`${image.id}:${image._note.id}`" - :to="image._note | notePage" - :title="`${image.name}\n${(new Date(image.createdAt)).toLocaleString()}`" - ></router-link> - </div> - </ui-container> - <ui-container :body-togglable="true" - :expanded="$store.state.device.expandUsersActivity" - @toggle="expanded => $store.commit('device/set', { key: 'expandUsersActivity', value: expanded })"> - <template #header><fa :icon="['far', 'chart-bar']"/> {{ $t('activity') }}</template> - <div> - <div ref="chart"></div> - </div> - </ui-container> - <ui-container> - <template #header><fa :icon="['far', 'comment-alt']"/> {{ $t('timeline') }}</template> - <div> - <x-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')" :key="user.id"/> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XNotes from './deck.notes.vue'; -import { concat } from '../../../../../prelude/array'; -import ApexCharts from 'apexcharts'; - -export default Vue.extend({ - i18n: i18n('deck/deck.user-column.vue'), - - components: { - XNotes, - XPage: () => import('../../../common/views/components/page/page.vue').then(m => m.default), - }, - - props: { - user: { - type: Object, - required: true - } - }, - - data() { - return { - withFiles: false, - images: [], - chart: null as ApexCharts - }; - }, - - computed: { - pagination() { - return { - endpoint: 'users/notes', - limit: 10, - params: init => ({ - userId: this.user.id, - untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), - withFiles: this.withFiles, - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes - }) - } - } - }, - - watch: { - user() { - this.fetch(); - } - }, - - created() { - this.fetch(); - }, - - methods: { - fetch() { - const image = [ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/apng', - 'image/vnd.mozilla.apng', - ]; - - this.$root.api('users/notes', { - userId: this.user.id, - fileType: image, - excludeNsfw: !this.$store.state.device.alwaysShowNsfw, - limit: 9, - }).then(notes => { - for (const note of notes) { - for (const file of note.files) { - file._note = note; - } - } - const files = concat(notes.map((n: any): any[] => n.files)); - this.images = files.filter(f => image.includes(f.type)).slice(0, 9); - }); - - this.$root.api('charts/user/notes', { - userId: this.user.id, - span: 'day', - limit: 21 - }).then(stats => { - const normal = []; - const reply = []; - const renote = []; - - const now = new Date(); - const y = now.getFullYear(); - const m = now.getMonth(); - const d = now.getDate(); - - for (let i = 0; i < 21; i++) { - const x = new Date(y, m, d - i); - normal.push([ - x, - stats.diffs.normal[i] - ]); - reply.push([ - x, - stats.diffs.reply[i] - ]); - renote.push([ - x, - stats.diffs.renote[i] - ]); - } - - if (this.chart) this.chart.destroy(); - - this.chart = new ApexCharts(this.$refs.chart, { - chart: { - type: 'bar', - stacked: true, - height: 100, - sparkline: { - enabled: true - }, - }, - plotOptions: { - bar: { - columnWidth: '80%' - } - }, - dataLabels: { - enabled: false - }, - grid: { - clipMarkers: false, - padding: { - top: 16, - right: 16, - bottom: 16, - left: 16 - } - }, - tooltip: { - shared: true, - intersect: false - }, - series: [{ - name: 'Normal', - data: normal - }, { - name: 'Reply', - data: reply - }, { - name: 'Renote', - data: renote - }], - xaxis: { - type: 'datetime', - crosshairs: { - width: 1, - opacity: 1 - } - } - }); - - this.chart.render(); - }); - }, - } -}); -</script> - -<style lang="stylus" scoped> -.sainvnaq - display grid - grid-template-columns 1fr 1fr 1fr - gap 8px - padding 16px - - > * - height 70px - background-position center center - background-size cover - background-clip content-box - border-radius 4px - -</style> diff --git a/src/client/app/common/views/deck/deck.user-column.vue b/src/client/app/common/views/deck/deck.user-column.vue deleted file mode 100644 index bc8cbc3154..0000000000 --- a/src/client/app/common/views/deck/deck.user-column.vue +++ /dev/null @@ -1,266 +0,0 @@ -<template> -<x-column> - <template #header> - <fa icon="user"/><mk-user-name :user="user" v-if="user" :key="user.id"/> - </template> - - <div class="zubukjlciycdsyynicqrnlsmdwmymzqu" v-if="user"> - <div class="is-remote" v-if="user.host != null"> - <details> - <summary><fa icon="exclamation-triangle"/> {{ $t('@.is-remote-user') }}</summary> - <a :href="user.url" rel="nofollow noopener" target="_blank">{{ $t('@.view-on-remote') }}</a> - </details> - </div> - <header :style="bannerStyle"> - <div> - <button class="menu" @click="menu" ref="menu"><fa icon="ellipsis-h"/></button> - <mk-follow-button v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" class="follow" mini/> - <mk-avatar class="avatar" :user="user" :disable-preview="true" :key="user.id"/> - <router-link class="name" :to="user | userPage()"> - <mk-user-name :user="user" :key="user.id" :nowrap="false"/> - </router-link> - <span class="acct">@{{ user | acct }} <fa v-if="user.isLocked == true" class="locked" icon="lock" fixed-width/></span> - <span class="followed" v-if="user.isFollowed">{{ $t('follows-you') }}</span> - </div> - </header> - <div class="info"> - <div class="description"> - <mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :key="user.id"/> - </div> - <div class="fields" v-if="user.fields" :key="user.id"> - <dl class="field" v-for="(field, i) in user.fields" :key="i"> - <dt class="name"> - <mfm :text="field.name" :plain="true" :custom-emojis="user.emojis"/> - </dt> - <dd class="value"> - <mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> - </dd> - </dl> - </div> - <div class="counts"> - <div> - <router-link :to="user | userPage()"> - <b>{{ user.notesCount | number }}</b> - <span>{{ $t('posts') }}</span> - </router-link> - </div> - <div> - <router-link :to="user | userPage('following')"> - <b>{{ user.followingCount | number }}</b> - <span>{{ $t('following') }}</span> - </router-link> - </div> - <div> - <router-link :to="user | userPage('followers')"> - <b>{{ user.followersCount | number }}</b> - <span>{{ $t('followers') }}</span> - </router-link> - </div> - </div> - </div> - <router-view :user="user"></router-view> - </div> -</x-column> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import parseAcct from '../../../../../misc/acct/parse'; -import XColumn from './deck.column.vue'; -import XUserMenu from '../../../common/views/components/user-menu.vue'; - -export default Vue.extend({ - i18n: i18n('deck/deck.user-column.vue'), - components: { - XColumn, - }, - - data() { - return { - user: null, - fetching: true, - }; - }, - - computed: { - bannerStyle(): any { - if (this.user == null) return {}; - if (this.user.bannerUrl == null) return {}; - return { - backgroundColor: this.user.bannerColor, - backgroundImage: `url(${ this.user.bannerUrl })` - }; - }, - }, - - watch: { - $route: 'fetch' - }, - - created() { - this.fetch(); - }, - - methods: { - fetch() { - this.fetching = true; - this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => { - this.user = user; - this.fetching = false; - }); - }, - - menu() { - const w = this.$root.new(XUserMenu, { - source: this.$refs.menu, - user: this.user - }); - this.$once('hook:beforeDestroy', () => { - w.destroyDom(); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.zubukjlciycdsyynicqrnlsmdwmymzqu - background var(--deckColumnBg) - - > .is-remote - padding 8px 16px - font-size 12px - - &.is-remote - color var(--remoteInfoFg) - background var(--remoteInfoBg) - - > a - font-weight bold - - > header - overflow hidden - background-size cover - background-position center - - > div - padding 32px - background rgba(#000, 0.5) - color #fff - text-align center - - > .menu - position absolute - top 8px - left 8px - padding 8px - font-size 16px - text-shadow 0 0 8px #000 - - > .follow - position absolute - top 16px - right 16px - - > .avatar - display block - width 64px - height 64px - margin 0 auto - - > .name - display block - margin-top 8px - font-weight bold - text-shadow 0 0 8px #000 - color #fff - - > .acct - display block - font-size 14px - opacity 0.7 - text-shadow 0 0 8px #000 - - > .locked - opacity 0.8 - - > .followed - display inline-block - font-size 12px - background rgba(0, 0, 0, 0.5) - opacity 0.7 - margin-top: 2px - padding 4px - border-radius 4px - - > .info - padding 16px - font-size 12px - color var(--text) - text-align center - background var(--face) - - &:before - content "" - display blcok - position absolute - top -32px - left 0 - right 0 - width 0 - margin 0 auto - border-top solid 16px transparent - border-left solid 16px transparent - border-right solid 16px transparent - border-bottom solid 16px var(--face) - - > .fields - margin-top 8px - - > .field - display flex - padding 0 - margin 0 - align-items center - - > .name - padding 4px - margin 4px - width 30% - overflow hidden - white-space nowrap - text-overflow ellipsis - font-weight bold - - > .value - padding 4px - margin 4px - width 70% - overflow hidden - white-space nowrap - text-overflow ellipsis - - > .counts - display grid - grid-template-columns 2fr 2fr 2fr - margin-top 8px - border-top solid var(--lineWidth) var(--faceDivider) - - > div - padding 8px 8px 0 8px - text-align center - - > a - color var(--text) - - > b - display block - font-size 110% - - > span - display block - font-size 80% - opacity 0.7 - -</style> diff --git a/src/client/app/common/views/deck/deck.vue b/src/client/app/common/views/deck/deck.vue deleted file mode 100644 index a3a26302e9..0000000000 --- a/src/client/app/common/views/deck/deck.vue +++ /dev/null @@ -1,394 +0,0 @@ -<template> -<mk-ui :class="$style.root"> - <div class="qlvquzbjribqcaozciifydkngcwtyzje" ref="body" :style="style" :class="`${$store.state.device.deckColumnAlign} ${$store.state.device.deckColumnWidth}`" v-hotkey.global="keymap"> - <template v-for="ids in layout"> - <div v-if="ids.length > 1" class="folder"> - <template v-for="id, i in ids"> - <x-column-core :ref="id" :key="id" :column="columns.find(c => c.id == id)" :is-stacked="true" @parentFocus="moveFocus(id, $event)"/> - </template> - </div> - <x-column-core v-else :ref="ids[0]" :key="ids[0]" :column="columns.find(c => c.id == ids[0])" @parentFocus="moveFocus(ids[0], $event)"/> - </template> - <router-view></router-view> - <button ref="add" @click="add" :title="$t('@deck.add-column')"><fa icon="plus"/></button> - </div> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XColumnCore from './deck.column-core.vue'; -import Menu from '../../../common/views/components/menu.vue'; - -import { v4 as uuid } from 'uuid'; - -export default Vue.extend({ - i18n: i18n('deck'), - - components: { - XColumnCore - }, - - computed: { - deck() { - return this.$store.getters.deck; - }, - - columns(): any[] { - if (this.deck == null) return []; - return this.deck.columns; - }, - - layout(): any[] { - if (this.deck == null) return []; - if (this.deck.layout == null) return this.deck.columns.map(c => [c.id]); - return this.deck.layout; - }, - - style(): any { - return { - height: `calc(100vh - ${this.$store.state.uiHeaderHeight}px)` - }; - }, - - keymap(): any { - return { - 't': this.focus - }; - } - }, - - watch: { - $route() { - if (this.$route.name == 'index') return; - this.$nextTick(() => { - this.$refs.body.scrollTo({ - left: this.$refs.body.scrollWidth - this.$refs.body.clientWidth, - behavior: 'smooth' - }); - }); - } - }, - - provide() { - return { - inDeck: true, - getColumnVm: this.getColumnVm, - narrow: true - }; - }, - - created() { - if (this.deck == null) { - const deck = { - columns: [/*{ - type: 'widgets', - widgets: [] - }, */{ - id: uuid(), - type: 'home', - name: null, - }, { - id: uuid(), - type: 'notifications', - name: null, - }, { - id: uuid(), - type: 'local', - name: null, - }, { - id: uuid(), - type: 'global', - name: null, - }] - }; - - deck.layout = deck.columns.map(c => [c.id]); - - this.$store.commit('setDeck', deck); - } - }, - - mounted() { - document.title = this.$root.instanceName; - document.documentElement.style.overflow = 'hidden'; - }, - - beforeDestroy() { - document.documentElement.style.overflow = 'auto'; - }, - - methods: { - getColumnVm(id) { - return this.$refs[id][0]; - }, - - add() { - this.$root.new(Menu, { - source: this.$refs.add, - items: [{ - icon: 'home', - text: this.$t('@deck.home'), - action: () => { - this.$store.commit('addDeckColumn', { - id: uuid(), - type: 'home' - }); - } - }, { - icon: ['far', 'comments'], - text: this.$t('@deck.local'), - action: () => { - this.$store.commit('addDeckColumn', { - id: uuid(), - type: 'local' - }); - } - }, { - icon: 'share-alt', - text: this.$t('@deck.hybrid'), - action: () => { - this.$store.commit('addDeckColumn', { - id: uuid(), - type: 'hybrid' - }); - } - }, { - icon: 'globe', - text: this.$t('@deck.global'), - action: () => { - this.$store.commit('addDeckColumn', { - id: uuid(), - type: 'global' - }); - } - }, { - icon: 'at', - text: this.$t('@deck.mentions'), - action: () => { - this.$store.commit('addDeckColumn', { - id: uuid(), - type: 'mentions' - }); - } - }, { - icon: ['far', 'envelope'], - text: this.$t('@deck.direct'), - action: () => { - this.$store.commit('addDeckColumn', { - id: uuid(), - type: 'direct' - }); - } - }, { - icon: 'list', - text: this.$t('@deck.list'), - action: async () => { - const lists = await this.$root.api('users/lists/list'); - const { canceled, result: listId } = await this.$root.dialog({ - type: null, - title: this.$t('@deck.select-list'), - select: { - items: lists.map(list => ({ - value: list.id, text: list.name - })) - }, - showCancelButton: true - }); - if (canceled) return; - this.$store.commit('addDeckColumn', { - id: uuid(), - type: 'list', - list: lists.find(l => l.id === listId) - }); - } - }, { - icon: 'hashtag', - text: this.$t('@deck.hashtag'), - action: () => { - this.$root.dialog({ - title: this.$t('enter-hashtag-tl-title'), - input: true - }).then(({ canceled, result: title }) => { - if (canceled) return; - this.$store.commit('addDeckColumn', { - id: uuid(), - type: 'hashtag', - tagTlId: this.$store.state.settings.tagTimelines.find(x => x.title == title).id - }); - }); - } - }, { - icon: ['far', 'bell'], - text: this.$t('@deck.notifications'), - action: () => { - this.$store.commit('addDeckColumn', { - id: uuid(), - type: 'notifications' - }); - } - }, { - icon: 'calculator', - text: this.$t('@deck.widgets'), - action: () => { - this.$store.commit('addDeckColumn', { - id: uuid(), - type: 'widgets', - widgets: [] - }); - } - }] - }); - }, - - focus() { - // Flatten array of arrays - const ids = [].concat.apply([], this.layout); - const firstTl = ids.find(id => this.isTlColumn(id)); - - if (firstTl) { - this.$refs[firstTl][0].focus(); - } - }, - - moveFocus(id, direction) { - let targetColumn; - - if (direction == 'right') { - const currentColumnIndex = this.layout.findIndex(ids => ids.includes(id)); - this.layout.some((ids, i) => { - if (i <= currentColumnIndex) return false; - const tl = ids.find(id => this.isTlColumn(id)); - if (tl) { - targetColumn = tl; - return true; - } - }); - } else if (direction == 'left') { - const currentColumnIndex = [...this.layout].reverse().findIndex(ids => ids.includes(id)); - [...this.layout].reverse().some((ids, i) => { - if (i <= currentColumnIndex) return false; - const tl = ids.find(id => this.isTlColumn(id)); - if (tl) { - targetColumn = tl; - return true; - } - }); - } else if (direction == 'down') { - const currentColumn = this.layout.find(ids => ids.includes(id)); - const currentIndex = currentColumn.indexOf(id); - currentColumn.some((_id, i) => { - if (i <= currentIndex) return false; - if (this.isTlColumn(_id)) { - targetColumn = _id; - return true; - } - }); - } else if (direction == 'up') { - const currentColumn = [...this.layout.find(ids => ids.includes(id))].reverse(); - const currentIndex = currentColumn.indexOf(id); - currentColumn.some((_id, i) => { - if (i <= currentIndex) return false; - if (this.isTlColumn(_id)) { - targetColumn = _id; - return true; - } - }); - } - - if (targetColumn) { - this.$refs[targetColumn][0].focus(); - } - }, - - isTlColumn(id) { - const column = this.columns.find(c => c.id === id); - return ['home', 'local', 'hybrid', 'global', 'list', 'hashtag', 'mentions', 'direct'].includes(column.type); - } - } -}); -</script> - -<style lang="stylus" module> -.root - height 100vh -</style> - -<style lang="stylus" scoped> -.qlvquzbjribqcaozciifydkngcwtyzje - display flex - flex 1 - padding 16px 0 16px 16px - overflow auto - overflow-y hidden - -webkit-overflow-scrolling touch - - @media (max-width 500px) - padding 8px 0 8px 8px - - > div - margin-right 8px - width 330px - min-width 330px - - &:last-of-type - margin-right 0 - - &.folder - display flex - flex-direction column - - > *:not(:last-child) - margin-bottom 8px - - &.narrow - > div - width 303px - min-width 303px - - &.narrower - > div - width 316.5px - min-width 316.5px - - &.wider - > div - width 343.5px - min-width 343.5px - - &.wide - > div - width 357px - min-width 357px - - &.center - > * - &:first-child - margin-left auto - - &:last-child - margin-right auto - - &.:not(.flexible) - > * - flex-grow 0 - flex-shrink 0 - - &.flexible - > * - flex-grow 1 - flex-shrink 0 - - > button - padding 0 16px - color var(--faceTextButton) - flex-grow 0 !important - - &:hover - color var(--faceTextButtonHover) - - &:active - color var(--faceTextButtonActive) - -</style> diff --git a/src/client/app/common/views/deck/deck.widgets-column.vue b/src/client/app/common/views/deck/deck.widgets-column.vue deleted file mode 100644 index d9a7747909..0000000000 --- a/src/client/app/common/views/deck/deck.widgets-column.vue +++ /dev/null @@ -1,173 +0,0 @@ -<template> -<x-column :menu="menu" :naked="true" :narrow="true" :name="name" :column="column" :is-stacked="isStacked" class="wtdtxvecapixsepjtcupubtsmometobz"> - <template #header><fa icon="calculator"/>{{ name }}</template> - - <div class="gqpwvtwtprsbmnssnbicggtwqhmylhnq"> - <template v-if="edit"> - <header> - <select v-model="widgetAdderSelected" @change="addWidget"> - <option value="profile">{{ $t('@.widgets.profile') }}</option> - <option value="analog-clock">{{ $t('@.widgets.analog-clock') }}</option> - <option value="calendar">{{ $t('@.widgets.calendar') }}</option> - <option value="timemachine">{{ $t('@.widgets.timemachine') }}</option> - <option value="activity">{{ $t('@.widgets.activity') }}</option> - <option value="rss">{{ $t('@.widgets.rss') }}</option> - <option value="trends">{{ $t('@.widgets.trends') }}</option> - <option value="photo-stream">{{ $t('@.widgets.photo-stream') }}</option> - <option value="slideshow">{{ $t('@.widgets.slideshow') }}</option> - <option value="version">{{ $t('@.widgets.version') }}</option> - <option value="broadcast">{{ $t('@.widgets.broadcast') }}</option> - <option value="notifications">{{ $t('@.widgets.notifications') }}</option> - <option value="users">{{ $t('@.widgets.users') }}</option> - <option value="polls">{{ $t('@.widgets.polls') }}</option> - <option value="post-form">{{ $t('@.widgets.post-form') }}</option> - <option value="messaging">{{ $t('@.messaging') }}</option> - <option value="memo">{{ $t('@.widgets.memo') }}</option> - <option value="hashtags">{{ $t('@.widgets.hashtags') }}</option> - <option value="posts-monitor">{{ $t('@.widgets.posts-monitor') }}</option> - <option value="server">{{ $t('@.widgets.server') }}</option> - <option value="queue">{{ $t('@.widgets.queue') }}</option> - <option value="nav">{{ $t('@.widgets.nav') }}</option> - <option value="tips">{{ $t('@.widgets.tips') }}</option> - </select> - </header> - <x-draggable - :list="column.widgets" - animation="150" - @sort="onWidgetSort" - > - <div v-for="widget in column.widgets" class="customize-container" :key="widget.id" @contextmenu.stop.prevent="widgetFunc(widget.id)"> - <button class="remove" @click="removeWidget(widget)"><fa icon="times"/></button> - <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" platform="deck" :column="column"/> - </div> - </x-draggable> - </template> - <template v-else> - <component class="widget" v-for="widget in column.widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" platform="deck" :column="column"/> - </template> - </div> -</x-column> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XColumn from './deck.column.vue'; -import * as XDraggable from 'vuedraggable'; -import { v4 as uuid } from 'uuid'; - -export default Vue.extend({ - i18n: i18n(), - components: { - XColumn, - XDraggable - }, - - props: { - column: { - type: Object, - required: true - }, - isStacked: { - type: Boolean, - required: true - } - }, - - data() { - return { - edit: false, - menu: null, - widgetAdderSelected: null - } - }, - - computed: { - name(): string { - if (this.column.name) return this.column.name; - return this.$t('@deck.widgets'); - } - }, - - created() { - this.menu = [{ - icon: 'cog', - text: this.$t('edit'), - action: () => { - this.edit = !this.edit; - } - }]; - }, - - methods: { - widgetFunc(id) { - const w = this.$refs[id][0]; - if (w.func) w.func(); - }, - - onWidgetSort() { - this.saveWidgets(); - }, - - addWidget() { - this.$store.commit('addDeckWidget', { - id: this.column.id, - widget: { - name: this.widgetAdderSelected, - id: uuid(), - data: {} - } - }); - - this.widgetAdderSelected = null; - }, - - removeWidget(widget) { - this.$store.commit('removeDeckWidget', { - id: this.column.id, - widget - }); - }, - - saveWidgets() { - this.$store.commit('updateDeckColumn', this.column); - } - } -}); -</script> - -<style lang="stylus" scoped> -.wtdtxvecapixsepjtcupubtsmometobz - .gqpwvtwtprsbmnssnbicggtwqhmylhnq - > header - padding 16px - - > * - width 100% - padding 4px - - .widget, .customize-container - margin 8px - - &:first-of-type - margin-top 0 - - .customize-container - cursor move - - > *:not(.remove) - pointer-events none - - > .remove - position absolute - z-index 1 - top 8px - right 8px - width 32px - height 32px - color #fff - background rgba(#000, 0.7) - border-radius 4px - -</style> - diff --git a/src/client/app/common/views/directives/index.ts b/src/client/app/common/views/directives/index.ts deleted file mode 100644 index 1bb4fd6d4d..0000000000 --- a/src/client/app/common/views/directives/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import Vue from 'vue'; - -import autocomplete from './autocomplete'; -import particle from './particle'; - -Vue.directive('autocomplete', autocomplete); -Vue.directive('particle', particle); diff --git a/src/client/app/common/views/directives/particle.ts b/src/client/app/common/views/directives/particle.ts deleted file mode 100644 index 5f8413117f..0000000000 --- a/src/client/app/common/views/directives/particle.ts +++ /dev/null @@ -1,26 +0,0 @@ -import Particle from '../components/particle.vue'; - -export default { - bind(el, binding, vn) { - if (vn.context.$store.state.device.reduceMotion) return; - - el.addEventListener('click', () => { - if (binding.value === false) return; - - const rect = el.getBoundingClientRect(); - - const x = rect.left + (el.clientWidth / 2); - const y = rect.top + (el.clientHeight / 2); - - const particle = new Particle({ - parent: vn.context, - propsData: { - x, - y - } - }).$mount(); - - document.body.appendChild(particle.$el); - }); - } -}; diff --git a/src/client/app/common/views/filters/index.ts b/src/client/app/common/views/filters/index.ts deleted file mode 100644 index 3dccbfc923..0000000000 --- a/src/client/app/common/views/filters/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -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'); -require('./note'); diff --git a/src/client/app/common/views/pages/explore.vue b/src/client/app/common/views/pages/explore.vue deleted file mode 100644 index b4a4e1d502..0000000000 --- a/src/client/app/common/views/pages/explore.vue +++ /dev/null @@ -1,198 +0,0 @@ -<template> -<div> - <div class="localfedi7" v-if="meta && stats && tag == null" :style="{ backgroundImage: meta.bannerUrl ? `url(${meta.bannerUrl})` : null }"> - <header>{{ $t('explore', { host: meta.name || 'Misskey' }) }}</header> - <div>{{ $t('users-info', { users: num(stats.originalUsersCount) }) }}</div> - </div> - - <template v-if="tag == null"> - <mk-user-list :pagination="pinnedUsers" :expanded="false"> - <fa :icon="faBookmark" fixed-width/>{{ $t('pinned-users') }} - </mk-user-list> - <mk-user-list :pagination="popularUsers" :expanded="false"> - <fa :icon="faChartLine" fixed-width/>{{ $t('popular-users') }} - </mk-user-list> - <mk-user-list :pagination="recentlyUpdatedUsers" :expanded="false"> - <fa :icon="faCommentAlt" fixed-width/>{{ $t('recently-updated-users') }} - </mk-user-list> - <mk-user-list :pagination="recentlyRegisteredUsers" :expanded="false"> - <fa :icon="faPlus" fixed-width/>{{ $t('recently-registered-users') }} - </mk-user-list> - </template> - - <div class="localfedi7" v-if="tag == null" :style="{ backgroundImage: `url(/assets/fedi.jpg)` }"> - <header>{{ $t('explore-fediverse') }}</header> - </div> - - <ui-container :body-togglable="true" :expanded="false" ref="tags"> - <template #header><fa :icon="faHashtag" fixed-width/>{{ $t('popular-tags') }}</template> - - <div class="vxjfqztj"> - <router-link v-for="tag in tagsLocal" :to="`/explore/tags/${tag.tag}`" :key="'local:' + tag.tag" class="local">{{ tag.tag }}</router-link> - <router-link v-for="tag in tagsRemote" :to="`/explore/tags/${tag.tag}`" :key="'remote:' + tag.tag">{{ tag.tag }}</router-link> - </div> - </ui-container> - - <mk-user-list v-if="tag != null" :pagination="tagUsers" :key="`${tag}`"> - <fa :icon="faHashtag" fixed-width/>{{ tag }} - </mk-user-list> - <template v-if="tag == null"> - <mk-user-list :pagination="popularUsersF" :expanded="false"> - <fa :icon="faChartLine" fixed-width/>{{ $t('popular-users') }} - </mk-user-list> - <mk-user-list :pagination="recentlyUpdatedUsersF" :expanded="false"> - <fa :icon="faCommentAlt" fixed-width/>{{ $t('recently-updated-users') }} - </mk-user-list> - <mk-user-list :pagination="recentlyRegisteredUsersF" :expanded="false"> - <fa :icon="faRocket" fixed-width/>{{ $t('recently-discovered-users') }} - </mk-user-list> - </template> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { faChartLine, faPlus, faHashtag, faRocket } from '@fortawesome/free-solid-svg-icons'; -import { faBookmark, faCommentAlt } from '@fortawesome/free-regular-svg-icons'; - -export default Vue.extend({ - i18n: i18n('common/views/pages/explore.vue'), - - props: { - tag: { - type: String, - required: false - } - }, - - inject: { - inNakedDeckColumn: { - default: false - } - }, - - data() { - return { - pinnedUsers: { endpoint: 'pinned-users' }, - popularUsers: { endpoint: 'users', limit: 10, params: { - state: 'alive', - origin: 'local', - sort: '+follower', - } }, - recentlyUpdatedUsers: { endpoint: 'users', limit: 10, params: { - origin: 'local', - sort: '+updatedAt', - } }, - recentlyRegisteredUsers: { endpoint: 'users', limit: 10, params: { - origin: 'local', - state: 'alive', - sort: '+createdAt', - } }, - popularUsersF: { endpoint: 'users', limit: 10, params: { - state: 'alive', - origin: 'remote', - sort: '+follower', - } }, - recentlyUpdatedUsersF: { endpoint: 'users', limit: 10, params: { - origin: 'combined', - sort: '+updatedAt', - } }, - recentlyRegisteredUsersF: { endpoint: 'users', limit: 10, params: { - origin: 'combined', - sort: '+createdAt', - } }, - tagsLocal: [], - tagsRemote: [], - stats: null, - meta: null, - num: Vue.filter('number'), - faBookmark, faChartLine, faCommentAlt, faPlus, faHashtag, faRocket - }; - }, - - computed: { - tagUsers(): any { - return { - endpoint: 'hashtags/users', - limit: 30, - params: { - tag: this.tag, - origin: 'combined', - sort: '+follower', - } - }; - }, - }, - - watch: { - tag() { - if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null); - } - }, - - created() { - this.$emit('init', { - title: this.$t('@.explore'), - icon: faHashtag - }); - this.$root.api('hashtags/list', { - sort: '+attachedLocalUsers', - attachedToLocalUserOnly: true, - limit: 30 - }).then(tags => { - this.tagsLocal = tags; - }); - this.$root.api('hashtags/list', { - sort: '+attachedRemoteUsers', - attachedToRemoteUserOnly: true, - limit: 30 - }).then(tags => { - this.tagsRemote = tags; - }); - this.$root.api('stats').then(stats => { - this.stats = stats; - }); - this.$root.getMeta().then(meta => { - this.meta = meta; - }); - }, - - mounted() { - document.title = this.$root.instanceName; - }, -}); -</script> - -<style lang="stylus" scoped> -.localfedi7 - overflow hidden - background var(--face) - color #fff - text-shadow 0 0 8px #000 - border-radius 6px - padding 16px - margin-top 16px - margin-bottom 16px - height 80px - background-position 50% - background-size cover - > header - font-size 20px - font-weight bold - > div - font-size 14px - opacity 0.8 - -.localfedi7:first-child - margin-top 0 - -.vxjfqztj - padding 16px - - > * - margin-right 16px - - &.local - font-weight bold -</style> diff --git a/src/client/app/common/views/pages/favorites.vue b/src/client/app/common/views/pages/favorites.vue deleted file mode 100644 index e396615a93..0000000000 --- a/src/client/app/common/views/pages/favorites.vue +++ /dev/null @@ -1,48 +0,0 @@ -<template> -<div> - <component :is="notesComponent" :pagination="pagination" :extract="items => items.map(item => item.note)"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faStar } from '@fortawesome/free-solid-svg-icons'; -import i18n from '../../../i18n'; -//import Progress from '../../../common/scripts/loading'; - -export default Vue.extend({ - i18n: i18n(), - - props: { - platform: { - type: String, - required: true - } - }, - - data() { - return { - pagination: { - endpoint: 'i/favorites', - limit: 10, - }, - - notesComponent: - this.platform === 'desktop' ? () => import('../../../desktop/views/components/detail-notes.vue').then(m => m.default) : - this.platform === 'mobile' ? () => import('../../../mobile/views/components/detail-notes.vue').then(m => m.default) : - this.platform === 'deck' ? () => import('../deck/deck.notes.vue').then(m => m.default) : null - }; - }, - - created() { - this.$emit('init', { - title: this.$t('@.favorites'), - icon: faStar - }); - }, - - mounted() { - document.title = this.$root.instanceName; - }, -}); -</script> diff --git a/src/client/app/common/views/pages/featured.vue b/src/client/app/common/views/pages/featured.vue deleted file mode 100644 index c00361aa85..0000000000 --- a/src/client/app/common/views/pages/featured.vue +++ /dev/null @@ -1,48 +0,0 @@ -<template> -<div> - <component :is="notesComponent" :pagination="pagination"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faNewspaper } from '@fortawesome/free-solid-svg-icons'; -import i18n from '../../../i18n'; -//import Progress from '../../../common/scripts/loading'; - -export default Vue.extend({ - i18n: i18n(), - - props: { - platform: { - type: String, - required: true - } - }, - - data() { - return { - pagination: { - endpoint: 'notes/featured', - limit: 29, - }, - - notesComponent: - this.platform === 'desktop' ? () => import('../../../desktop/views/components/detail-notes.vue').then(m => m.default) : - this.platform === 'mobile' ? () => import('../../../mobile/views/components/detail-notes.vue').then(m => m.default) : - this.platform === 'deck' ? () => import('../deck/deck.notes.vue').then(m => m.default) : null - }; - }, - - created() { - this.$emit('init', { - title: this.$t('@.featured-notes'), - icon: faNewspaper - }); - }, - - mounted() { - document.title = this.$root.instanceName; - }, -}); -</script> diff --git a/src/client/app/common/views/pages/follow-requests.vue b/src/client/app/common/views/pages/follow-requests.vue deleted file mode 100644 index 07ff7b7d54..0000000000 --- a/src/client/app/common/views/pages/follow-requests.vue +++ /dev/null @@ -1,75 +0,0 @@ -<template> -<div> - <ui-container :body-togglable="true"> - <template #header>{{ $t('received-follow-requests') }}</template> - <div v-if="!fetching"> - <sequential-entrance animation="entranceFromTop" delay="25" tag="div"> - <div v-for="req in requests" class="mcbzkkaw"> - <router-link :key="req.id" :to="req.follower | userPage"> - <mk-user-name :user="req.follower"/> - </router-link> - <span> - <a @click="accept(req.follower)">{{ $t('accept') }}</a> | <a @click="reject(req.follower)">{{ $t('reject') }}</a> - </span> - </div> - </sequential-entrance> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import Progress from '../../scripts/loading'; -import { faUserClock } from '@fortawesome/free-solid-svg-icons'; - -export default Vue.extend({ - i18n: i18n('common/views/pages/follow-requests.vue'), - data() { - return { - fetching: true, - requests: [] - }; - }, - created() { - this.$emit('init', { - title: this.$t('received-follow-requests'), - icon: faUserClock - }); - }, - mounted() { - Progress.start(); - this.$root.api('following/requests/list').then(requests => { - this.fetching = false; - this.requests = requests; - Progress.done(); - }); - }, - methods: { - accept(user) { - this.$root.api('following/requests/accept', { userId: user.id }).then(() => { - this.requests = this.requests.filter(r => r.follower.id != user.id); - }); - }, - reject(user) { - this.$root.api('following/requests/reject', { userId: user.id }).then(() => { - this.requests = this.requests.filter(r => r.follower.id != user.id); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mcbzkkaw - display flex - padding 16px - border solid 1px var(--faceDivider) - border-radius 4px - - > span - margin 0 0 0 auto - color var(--text) - -</style> diff --git a/src/client/app/common/views/pages/follow.vue b/src/client/app/common/views/pages/follow.vue deleted file mode 100644 index c7b07a5be2..0000000000 --- a/src/client/app/common/views/pages/follow.vue +++ /dev/null @@ -1,242 +0,0 @@ -<template> -<div class="syxhndwprovvuqhmyvveewmbqayniwkv" v-if="!fetching"> - <div class="signed-in-as"> - <mfm :text="$t('signed-in-as').replace('{}', myName)" :plain="true" :custom-emojis="$store.state.i.emojis"/> - </div> - <main> - <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"> - <mk-user-name :user="user"/> - </router-link> - <span class="username">@{{ user | acct }}</span> - <div class="description"> - <mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> - </div> - </div> - </main> - - <button - :class="{ wait: followWait, active: user.isFollowing || user.hasPendingFollowRequestFromYou }" - @click="onClick" - :disabled="followWait"> - <template v-if="!followWait"> - <template v-if="user.hasPendingFollowRequestFromYou && user.isLocked"><fa icon="hourglass-half"/> {{ $t('request-pending') }}</template> - <template v-else-if="user.hasPendingFollowRequestFromYou && !user.isLocked"><fa icon="spinner"/> {{ $t('follow-processing') }}</template> - <template v-else-if="user.isFollowing"><fa icon="minus"/> {{ $t('following') }}</template> - <template v-else-if="!user.isFollowing && user.isLocked"><fa icon="plus"/> {{ $t('follow-request') }}</template> - <template v-else-if="!user.isFollowing && !user.isLocked"><fa icon="plus"/> {{ $t('follow') }}</template> - </template> - <template v-else><fa icon="spinner" pulse fixed-width/></template> - </button> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import parseAcct from '../../../../../misc/acct/parse'; -import Progress from '../../../common/scripts/loading'; - -export default Vue.extend({ - i18n: i18n('common/views/pages/follow.vue'), - data() { - return { - fetching: true, - user: null, - followWait: false - }; - }, - - computed: { - myName(): string { - return Vue.filter('userName')(this.$store.state.i); - }, - - bannerStyle(): any { - if (this.user.bannerUrl == null) return {}; - return { - backgroundColor: this.user.bannerColor, - backgroundImage: `url(${ this.user.bannerUrl })` - }; - } - }, - - created() { - this.fetch(); - }, - - methods: { - fetch() { - const acct = new URL(location.href).searchParams.get('acct'); - this.fetching = true; - Progress.start(); - if (acct.match(/^https?:/)) { - this.$root.api('ap/show', { - uri: acct - }).then((res: { type: string, object: any }) => { - if (res.type === 'User') { - this.user = res.object; - } else if (res.type === 'Note') { - this.$router.replace(`/notes/${res.object.id}`); - } else { - this.$root.dialog({ - type: 'error', - text: 'Not supported' - }); - } - }).catch((e: any) => { - this.$root.dialog({ - type: 'error', - text: e.message - }); - }).finally(() => { - this.fetching = false; - Progress.done(); - }); - } else { - this.$root.api('users/show', parseAcct(acct)).then((user: any) => { - this.user = user; - }).catch((e: any) => { - this.$root.dialog({ - type: 'error', - text: e.message - }); - }).finally(() => { - this.fetching = false; - Progress.done(); - }); - } - }, - - async onClick() { - this.followWait = true; - - try { - if (this.user.isFollowing) { - this.user = await this.$root.api('following/delete', { - userId: this.user.id - }); - } else { - if (this.user.hasPendingFollowRequestFromYou) { - this.user = await this.$root.api('following/requests/cancel', { - userId: this.user.id - }); - } else if (this.user.isLocked) { - this.user = await this.$root.api('following/create', { - userId: this.user.id - }); - } else { - this.user = await this.$root.api('following/create', { - userId: this.user.id - }); - } - } - } catch (e) { - console.error(e); - } finally { - this.followWait = false; - } - } - } -}); -</script> - -<style lang="stylus" scoped> -.syxhndwprovvuqhmyvveewmbqayniwkv - padding 32px - max-width 500px - margin 0 auto - text-align center - color var(--text) - - $bg = var(--face) - - @media (max-width 400px) - padding 16px - - > .signed-in-as - margin-bottom 16px - font-size 14px - font-weight bold - - > main - margin-bottom 16px - background $bg - border-radius 8px - box-shadow 0 4px 12px rgba(#000, 0.1) - overflow hidden - - > .banner - height 128px - background-position center - background-size cover - - > .avatar - display block - margin -50px auto 0 auto - width 100px - height 100px - border-radius 100% - border solid 4px $bg - - > .body - padding 4px 32px 32px 32px - - @media (max-width 400px) - padding 4px 16px 16px 16px - - > .name - font-size 20px - font-weight bold - - > .username - display block - opacity 0.7 - - > .description - margin-top 16px - - > button - display block - user-select none - cursor pointer - padding 10px 16px - margin 0 - width 100% - min-width 150px - font-size 14px - font-weight bold - color var(--primary) - background transparent - outline none - border solid 1px var(--primary) - border-radius 36px - - &:hover - background var(--primaryAlpha01) - - &:active - background var(--primaryAlpha02) - - &.active - color var(--primaryForeground) - background var(--primary) - - &:hover - background var(--primaryLighten10) - border-color var(--primaryLighten10) - - &:active - background var(--primaryDarken10) - border-color var(--primaryDarken10) - - &.wait - cursor wait !important - opacity 0.7 - - * - pointer-events none - -</style> diff --git a/src/client/app/common/views/pages/followers.vue b/src/client/app/common/views/pages/followers.vue deleted file mode 100644 index b546e69ae3..0000000000 --- a/src/client/app/common/views/pages/followers.vue +++ /dev/null @@ -1,25 +0,0 @@ -<template> -<mk-user-list :pagination="pagination" :extract="items => items.map(item => item.follower)">{{ $t('@.followers') }}</mk-user-list> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import parseAcct from '../../../../../misc/acct/parse'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n(), - - data() { - return { - pagination: { - endpoint: 'users/followers', - limit: 30, - params: { - ...parseAcct(this.$route.params.user), - } - }, - }; - }, -}); -</script> diff --git a/src/client/app/common/views/pages/following.vue b/src/client/app/common/views/pages/following.vue deleted file mode 100644 index 4e584c19d9..0000000000 --- a/src/client/app/common/views/pages/following.vue +++ /dev/null @@ -1,25 +0,0 @@ -<template> -<mk-user-list :pagination="pagination" :extract="items => items.map(item => item.followee)">{{ $t('@.following') }}</mk-user-list> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import parseAcct from '../../../../../misc/acct/parse'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n(), - - data() { - return { - pagination: { - endpoint: 'users/following', - limit: 30, - params: { - ...parseAcct(this.$route.params.user), - } - }, - }; - }, -}); -</script> diff --git a/src/client/app/common/views/pages/not-found.vue b/src/client/app/common/views/pages/not-found.vue deleted file mode 100644 index cb1b19687c..0000000000 --- a/src/client/app/common/views/pages/not-found.vue +++ /dev/null @@ -1,65 +0,0 @@ -<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/not-found.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/page-editor/els/page-editor.el.button.vue b/src/client/app/common/views/pages/page-editor/els/page-editor.el.button.vue deleted file mode 100644 index 6a82b0eec9..0000000000 --- a/src/client/app/common/views/pages/page-editor/els/page-editor.el.button.vue +++ /dev/null @@ -1,80 +0,0 @@ -<template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faBolt"/> {{ $t('blocks.button') }}</template> - - <section class="xfhsjczc"> - <ui-input v-model="value.text"><span>{{ $t('blocks._button.text') }}</span></ui-input> - <ui-switch v-model="value.primary"><span>{{ $t('blocks._button.colored') }}</span></ui-switch> - <ui-select v-model="value.action"> - <template #label>{{ $t('blocks._button.action') }}</template> - <option value="dialog">{{ $t('blocks._button._action.dialog') }}</option> - <option value="resetRandom">{{ $t('blocks._button._action.resetRandom') }}</option> - <option value="pushEvent">{{ $t('blocks._button._action.pushEvent') }}</option> - </ui-select> - <template v-if="value.action === 'dialog'"> - <ui-input v-model="value.content"><span>{{ $t('blocks._button._action._dialog.content') }}</span></ui-input> - </template> - <template v-else-if="value.action === 'pushEvent'"> - <ui-input v-model="value.event"><span>{{ $t('blocks._button._action._pushEvent.event') }}</span></ui-input> - <ui-input v-model="value.message"><span>{{ $t('blocks._button._action._pushEvent.message') }}</span></ui-input> - <ui-select v-model="value.var"> - <template #label>{{ $t('blocks._button._action._pushEvent.variable') }}</template> - <option :value="null">{{ $t('blocks._button._action._pushEvent.no-variable') }}</option> - <option v-for="v in aiScript.getVarsByType()" :value="v.name">{{ v.name }}</option> - <optgroup :label="$t('script.pageVariables')"> - <option v-for="v in aiScript.getPageVarsByType()" :value="v">{{ v }}</option> - </optgroup> - <optgroup :label="$t('script.enviromentVariables')"> - <option v-for="v in aiScript.getEnvVarsByType()" :value="v">{{ v }}</option> - </optgroup> - </ui-select> - </template> - </section> -</x-container> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faBolt } from '@fortawesome/free-solid-svg-icons'; -import i18n from '../../../../../i18n'; -import XContainer from '../page-editor.container.vue'; - -export default Vue.extend({ - i18n: i18n('pages'), - - components: { - XContainer - }, - - props: { - value: { - required: true - }, - aiScript: { - required: true, - }, - }, - - data() { - return { - faBolt - }; - }, - - created() { - if (this.value.text == null) Vue.set(this.value, 'text', ''); - if (this.value.action == null) Vue.set(this.value, 'action', 'dialog'); - if (this.value.content == null) Vue.set(this.value, 'content', null); - if (this.value.event == null) Vue.set(this.value, 'event', null); - if (this.value.message == null) Vue.set(this.value, 'message', null); - if (this.value.primary == null) Vue.set(this.value, 'primary', false); - if (this.value.var == null) Vue.set(this.value, 'var', null); - }, -}); -</script> - -<style lang="stylus" scoped> -.xfhsjczc - padding 0 16px 0 16px - -</style> diff --git a/src/client/app/common/views/pages/page-editor/els/page-editor.el.number-input.vue b/src/client/app/common/views/pages/page-editor/els/page-editor.el.number-input.vue deleted file mode 100644 index 30c3938111..0000000000 --- a/src/client/app/common/views/pages/page-editor/els/page-editor.el.number-input.vue +++ /dev/null @@ -1,42 +0,0 @@ -<template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faBolt"/> {{ $t('blocks.numberInput') }}</template> - - <section style="padding: 0 16px 0 16px;"> - <ui-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('blocks._numberInput.name') }}</span></ui-input> - <ui-input v-model="value.text"><span>{{ $t('blocks._numberInput.text') }}</span></ui-input> - <ui-input v-model="value.default" type="number"><span>{{ $t('blocks._numberInput.default') }}</span></ui-input> - </section> -</x-container> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons'; -import i18n from '../../../../../i18n'; -import XContainer from '../page-editor.container.vue'; - -export default Vue.extend({ - i18n: i18n('pages'), - - components: { - XContainer - }, - - props: { - value: { - required: true - }, - }, - - data() { - return { - faBolt, faMagic - }; - }, - - created() { - if (this.value.name == null) Vue.set(this.value, 'name', ''); - }, -}); -</script> diff --git a/src/client/app/common/views/pages/page-editor/els/page-editor.el.switch.vue b/src/client/app/common/views/pages/page-editor/els/page-editor.el.switch.vue deleted file mode 100644 index 174a344640..0000000000 --- a/src/client/app/common/views/pages/page-editor/els/page-editor.el.switch.vue +++ /dev/null @@ -1,48 +0,0 @@ -<template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faBolt"/> {{ $t('blocks.switch') }}</template> - - <section class="kjuadyyj"> - <ui-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('blocks._switch.name') }}</span></ui-input> - <ui-input v-model="value.text"><span>{{ $t('blocks._switch.text') }}</span></ui-input> - <ui-switch v-model="value.default"><span>{{ $t('blocks._switch.default') }}</span></ui-switch> - </section> -</x-container> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons'; -import i18n from '../../../../../i18n'; -import XContainer from '../page-editor.container.vue'; - -export default Vue.extend({ - i18n: i18n('pages'), - - components: { - XContainer - }, - - props: { - value: { - required: true - }, - }, - - data() { - return { - faBolt, faMagic - }; - }, - - created() { - if (this.value.name == null) Vue.set(this.value, 'name', ''); - }, -}); -</script> - -<style lang="stylus" scoped> -.kjuadyyj - padding 0 16px 16px 16px - -</style> diff --git a/src/client/app/common/views/pages/page-editor/els/page-editor.el.text-input.vue b/src/client/app/common/views/pages/page-editor/els/page-editor.el.text-input.vue deleted file mode 100644 index 50f95fd205..0000000000 --- a/src/client/app/common/views/pages/page-editor/els/page-editor.el.text-input.vue +++ /dev/null @@ -1,42 +0,0 @@ -<template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faBolt"/> {{ $t('blocks.textInput') }}</template> - - <section style="padding: 0 16px 0 16px;"> - <ui-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('blocks._textInput.name') }}</span></ui-input> - <ui-input v-model="value.text"><span>{{ $t('blocks._textInput.text') }}</span></ui-input> - <ui-input v-model="value.default" type="text"><span>{{ $t('blocks._textInput.default') }}</span></ui-input> - </section> -</x-container> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons'; -import i18n from '../../../../../i18n'; -import XContainer from '../page-editor.container.vue'; - -export default Vue.extend({ - i18n: i18n('pages'), - - components: { - XContainer - }, - - props: { - value: { - required: true - }, - }, - - data() { - return { - faBolt, faMagic - }; - }, - - created() { - if (this.value.name == null) Vue.set(this.value, 'name', ''); - }, -}); -</script> diff --git a/src/client/app/common/views/pages/page-editor/els/page-editor.el.textarea-input.vue b/src/client/app/common/views/pages/page-editor/els/page-editor.el.textarea-input.vue deleted file mode 100644 index da3eead080..0000000000 --- a/src/client/app/common/views/pages/page-editor/els/page-editor.el.textarea-input.vue +++ /dev/null @@ -1,42 +0,0 @@ -<template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faBolt"/> {{ $t('blocks.textareaInput') }}</template> - - <section style="padding: 0 16px 16px 16px;"> - <ui-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('blocks._textareaInput.name') }}</span></ui-input> - <ui-input v-model="value.text"><span>{{ $t('blocks._textareaInput.text') }}</span></ui-input> - <ui-textarea v-model="value.default"><span>{{ $t('blocks._textareaInput.default') }}</span></ui-textarea> - </section> -</x-container> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons'; -import i18n from '../../../../../i18n'; -import XContainer from '../page-editor.container.vue'; - -export default Vue.extend({ - i18n: i18n('pages'), - - components: { - XContainer - }, - - props: { - value: { - required: true - }, - }, - - data() { - return { - faBolt, faMagic - }; - }, - - created() { - if (this.value.name == null) Vue.set(this.value, 'name', ''); - }, -}); -</script> diff --git a/src/client/app/common/views/pages/page-editor/page-editor.container.vue b/src/client/app/common/views/pages/page-editor/page-editor.container.vue deleted file mode 100644 index a3a501afb4..0000000000 --- a/src/client/app/common/views/pages/page-editor/page-editor.container.vue +++ /dev/null @@ -1,146 +0,0 @@ -<template> -<div class="cpjygsrt" :class="{ error: error != null, warn: warn != null }"> - <header> - <div class="title"><slot name="header"></slot></div> - <div class="buttons"> - <slot name="func"></slot> - <button v-if="removable" @click="remove()"> - <fa :icon="faTrashAlt"/> - </button> - <button v-if="draggable" class="drag-handle"> - <fa :icon="faBars"/> - </button> - <button @click="toggleContent(!showBody)"> - <template v-if="showBody"><fa icon="angle-up"/></template> - <template v-else><fa icon="angle-down"/></template> - </button> - </div> - </header> - <p v-show="showBody" class="error" v-if="error != null">{{ $t('script.typeError', { slot: error.arg + 1, expect: $t(`script.types.${error.expect}`), actual: $t(`script.types.${error.actual}`) }) }}</p> - <p v-show="showBody" class="warn" v-if="warn != null">{{ $t('script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p> - <div v-show="showBody"> - <slot></slot> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faBars } from '@fortawesome/free-solid-svg-icons'; -import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; -import i18n from '../../../../i18n'; - -export default Vue.extend({ - i18n: i18n('pages'), - - props: { - expanded: { - type: Boolean, - default: true - }, - removable: { - type: Boolean, - default: true - }, - draggable: { - type: Boolean, - default: false - }, - error: { - required: false, - default: null - }, - warn: { - required: false, - default: null - } - }, - data() { - return { - showBody: this.expanded, - faTrashAlt, faBars - }; - }, - methods: { - toggleContent(show: boolean) { - this.showBody = show; - this.$emit('toggle', show); - }, - remove() { - this.$emit('remove'); - } - } -}); -</script> - -<style lang="stylus" scoped> -.cpjygsrt - overflow hidden - background var(--face) - border solid 2px var(--pageBlockBorder) - border-radius 6px - - &:hover - border solid 2px var(--pageBlockBorderHover) - - &.warn - border solid 2px #dec44c - - &.error - border solid 2px #f00 - - & + .cpjygsrt - margin-top 16px - - > header - > .title - z-index 1 - margin 0 - padding 0 16px - line-height 42px - font-size 0.9em - font-weight bold - color var(--faceHeaderText) - box-shadow 0 1px rgba(#000, 0.07) - - > [data-icon] - margin-right 6px - - &:empty - display none - - > .buttons - position absolute - z-index 2 - top 0 - right 0 - - > button - padding 0 - width 42px - font-size 0.9em - line-height 42px - color var(--faceTextButton) - - &:hover - color var(--faceTextButtonHover) - - &:active - color var(--faceTextButtonActive) - - .drag-handle - cursor move - - > .warn - color #b19e49 - margin 0 - padding 16px 16px 0 16px - font-size 14px - - > .error - color #f00 - margin 0 - padding 16px 16px 0 16px - font-size 14px - -</style> diff --git a/src/client/app/common/views/pages/pages.vue b/src/client/app/common/views/pages/pages.vue deleted file mode 100644 index 236330db46..0000000000 --- a/src/client/app/common/views/pages/pages.vue +++ /dev/null @@ -1,80 +0,0 @@ -<template> -<div> - <ui-container :body-togglable="true"> - <template #header><fa :icon="faEdit" fixed-width/>{{ $t('my-pages') }}</template> - <div class="rknalgpo my"> - <ui-button class="new" @click="create()"><fa :icon="faPlus"/></ui-button> - <ui-pagination :pagination="myPagesPagination" #default="{items}"> - <x-page-preview v-for="page in items" class="ckltabjg" :page="page" :key="page.id"/> - </ui-pagination> - </div> - </ui-container> - - <ui-container :body-togglable="true"> - <template #header><fa :icon="faHeart" fixed-width/>{{ $t('liked-pages') }}</template> - <div class="rknalgpo"> - <ui-pagination :pagination="likedPagesPagination" #default="{items}"> - <x-page-preview v-for="like in items" class="ckltabjg" :page="like.page" :key="like.page.id"/> - </ui-pagination> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faPlus, faEdit } from '@fortawesome/free-solid-svg-icons'; -import { faStickyNote, faHeart } from '@fortawesome/free-regular-svg-icons'; -import i18n from '../../../i18n'; -import XPagePreview from '../../views/components/page-preview.vue'; - -export default Vue.extend({ - i18n: i18n('pages'), - components: { - XPagePreview - }, - data() { - return { - myPagesPagination: { - endpoint: 'i/pages', - limit: 5, - }, - likedPagesPagination: { - endpoint: 'i/page-likes', - limit: 5, - }, - faStickyNote, faPlus, faEdit, faHeart - }; - }, - created() { - this.$emit('init', { - title: this.$t('@.pages'), - icon: faStickyNote - }); - }, - mounted() { - document.title = this.$root.instanceName; - }, - methods: { - create() { - this.$router.push(`/i/pages/new`); - } - } -}); -</script> - -<style lang="stylus" scoped> -.rknalgpo - padding 16px - - &.my .ckltabjg:first-child - margin-top 16px - - .ckltabjg:not(:last-child) - margin-bottom 8px - - @media (min-width 500px) - .ckltabjg:not(:last-child) - margin-bottom 16px - -</style> diff --git a/src/client/app/common/views/pages/room/preview.vue b/src/client/app/common/views/pages/room/preview.vue deleted file mode 100644 index 94c13cee9f..0000000000 --- a/src/client/app/common/views/pages/room/preview.vue +++ /dev/null @@ -1,106 +0,0 @@ -<template> -<canvas width=224 height=128></canvas> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import * as THREE from 'three'; - -export default Vue.extend({ - data() { - return { - selected: null, - objectHeight: 0, - orbitRadius: 5 - }; - }, - - mounted() { - const canvas = this.$el; - - const width = canvas.width; - const height = canvas.height; - - const scene = new THREE.Scene(); - - const renderer = new THREE.WebGLRenderer({ - canvas: canvas, - antialias: true, - alpha: false - }); - renderer.setPixelRatio(window.devicePixelRatio); - renderer.setSize(width, height); - renderer.setClearColor(0x000000); - renderer.autoClear = false; - renderer.shadowMap.enabled = true; - renderer.shadowMap.cullFace = THREE.CullFaceBack; - - const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 100); - camera.zoom = 10; - camera.position.x = 0; - camera.position.y = 2; - camera.position.z = 0; - camera.updateProjectionMatrix(); - scene.add(camera); - - const ambientLight = new THREE.AmbientLight(0xffffff, 1); - ambientLight.castShadow = false; - scene.add(ambientLight); - - const light = new THREE.PointLight(0xffffff, 1, 100); - light.position.set(3, 3, 3); - scene.add(light); - - const grid = new THREE.GridHelper(5, 16, 0x444444, 0x222222); - scene.add(grid); - - const render = () => { - const timer = Date.now() * 0.0004; - requestAnimationFrame(render); - - camera.position.y = Math.sin(Math.PI / 6) * this.orbitRadius; // Math.PI / 6 => 30deg - camera.position.z = Math.cos(timer) * this.orbitRadius; - camera.position.x = Math.sin(timer) * this.orbitRadius; - camera.lookAt(new THREE.Vector3(0, this.objectHeight / 2, 0)); - renderer.render(scene, camera); - }; - - this.selected = selected => { - const obj = selected.clone(); - - // Remove current object - const current = scene.getObjectByName('obj'); - if (current != null) { - scene.remove(current); - } - - // Add new object - obj.name = 'obj'; - obj.position.x = 0; - obj.position.y = 0; - obj.position.z = 0; - obj.rotation.x = 0; - obj.rotation.y = 0; - obj.rotation.z = 0; - obj.traverse(child => { - if (child instanceof THREE.Mesh) { - child.material = child.material.clone(); - return child.material.emissive.setHex(0x000000); - } - }); - const objectBoundingBox = new THREE.Box3().setFromObject(obj); - this.objectHeight = objectBoundingBox.max.y - objectBoundingBox.min.y; - - const objectWidth = objectBoundingBox.max.x - objectBoundingBox.min.x; - const objectDepth = objectBoundingBox.max.z - objectBoundingBox.min.z; - - const horizontal = Math.hypot(objectWidth, objectDepth) / camera.aspect; - this.orbitRadius = Math.max(horizontal, this.objectHeight) * camera.zoom * 0.625 / Math.tan(camera.fov * 0.5 * (Math.PI / 180)); - - scene.add(obj); - }; - - render(); - }, -}); -</script> diff --git a/src/client/app/common/views/pages/room/room.vue b/src/client/app/common/views/pages/room/room.vue deleted file mode 100644 index 1e81920c22..0000000000 --- a/src/client/app/common/views/pages/room/room.vue +++ /dev/null @@ -1,310 +0,0 @@ -<template> -<div class="hveuntkp"> - <div class="controller" v-if="objectSelected"> - <section> - <p class="name">{{ selectedFurnitureName }}</p> - <x-preview ref="preview"/> - <template v-if="selectedFurnitureInfo.props"> - <div v-for="k in Object.keys(selectedFurnitureInfo.props)" :key="k"> - <p>{{ k }}</p> - <template v-if="selectedFurnitureInfo.props[k] === 'image'"> - <ui-button @click="chooseImage(k)">{{ $t('chooseImage') }}</ui-button> - </template> - <template v-else-if="selectedFurnitureInfo.props[k] === 'color'"> - <input type="color" :value="selectedFurnitureProps ? selectedFurnitureProps[k] : null" @change="updateColor(k, $event)"/> - </template> - </div> - </template> - </section> - <section> - <ui-button @click="translate()" :primary="isTranslateMode"><fa :icon="faArrowsAlt"/> {{ $t('translate') }}</ui-button> - <ui-button @click="rotate()" :primary="isRotateMode"><fa :icon="faUndo"/> {{ $t('rotate') }}</ui-button> - <ui-button v-if="isTranslateMode || isRotateMode" @click="exit()"><fa :icon="faBan"/> {{ $t('exit') }}</ui-button> - </section> - <section> - <ui-button @click="remove()"><fa :icon="faTrashAlt"/> {{ $t('remove') }}</ui-button> - </section> - </div> - - <div class="menu" v-if="isMyRoom"> - <section> - <ui-button @click="add()"><fa :icon="faBoxOpen"/> {{ $t('add-furniture') }}</ui-button> - </section> - <section> - <ui-select :value="roomType" @input="updateRoomType($event)"> - <template #label>{{ $t('room-type') }}</template> - <option value="default">{{ $t('rooms.default') }}</option> - <option value="washitsu">{{ $t('rooms.washitsu') }}</option> - </ui-select> - <label v-if="roomType === 'default'"> - <span>{{ $t('carpet-color') }}</span> - <input type="color" :value="carpetColor" @change="updateCarpetColor($event)"/> - </label> - </section> - <section> - <ui-button :primary="changed" @click="save()"><fa :icon="faSave"/> {{ $t('save') }}</ui-button> - <ui-button @click="clear()"><fa :icon="faBroom"/> {{ $t('clear') }}</ui-button> - </section> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; -import { Room } from '../../../scripts/room/room'; -import parseAcct from '../../../../../../misc/acct/parse'; -import XPreview from './preview.vue'; -const storeItems = require('../../../scripts/room/furnitures.json5'); -import { faBoxOpen, faUndo, faArrowsAlt, faBan, faBroom } from '@fortawesome/free-solid-svg-icons'; -import { faSave, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; -import { query as urlQuery } from '../../../../../../prelude/url'; - -let room: Room; - -export default Vue.extend({ - i18n: i18n('room'), - - components: { - XPreview - }, - - props: { - acct: { - type: String, - required: true - }, - }, - - data() { - return { - objectSelected: false, - selectedFurnitureName: null, - selectedFurnitureInfo: null, - selectedFurnitureProps: null, - roomType: null, - carpetColor: null, - isTranslateMode: false, - isRotateMode: false, - isMyRoom: false, - changed: false, - faBoxOpen, faSave, faTrashAlt, faUndo, faArrowsAlt, faBan, faBroom, - }; - }, - - async mounted() { - window.addEventListener('beforeunload', this.beforeunload); - - const user = await this.$root.api('users/show', { - ...parseAcct(this.acct) - }); - - this.isMyRoom = this.$store.getters.isSignedIn && this.$store.state.i.id === user.id; - - const roomInfo = await this.$root.api('room/show', { - userId: user.id - }); - - this.roomType = roomInfo.roomType; - this.carpetColor = roomInfo.carpetColor; - - room = new Room(user, this.isMyRoom, roomInfo, this.$el, { - graphicsQuality: this.$store.state.device.roomGraphicsQuality, - onChangeSelect: obj => { - this.objectSelected = obj != null; - if (obj) { - const f = room.findFurnitureById(obj.name); - this.selectedFurnitureName = this.$t('furnitures.' + f.type); - this.selectedFurnitureInfo = storeItems.find(x => x.id === f.type); - this.selectedFurnitureProps = f.props - ? JSON.parse(JSON.stringify(f.props)) // Disable reactivity - : null; - this.$nextTick(() => { - this.$refs.preview.selected(obj); - }); - } - }, - useOrthographicCamera: this.$store.state.device.roomUseOrthographicCamera - }); - }, - - beforeRouteLeave(to, from, next) { - if (this.changed) { - this.$root.dialog({ - type: 'warning', - text: this.$t('leave-confirm'), - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) { - next(false); - } else { - next(); - } - }); - } else { - next(); - } - }, - - beforeDestroy() { - room.destroy(); - window.removeEventListener('beforeunload', this.beforeunload); - }, - - methods: { - beforeunload(e: BeforeUnloadEvent) { - if (this.changed) { - e.preventDefault(); - e.returnValue = ''; - } - }, - - async add() { - const { canceled, result: id } = await this.$root.dialog({ - type: null, - title: this.$t('add-furniture'), - select: { - items: storeItems.map(item => ({ - value: item.id, text: this.$t('furnitures.' + item.id) - })) - }, - showCancelButton: true - }); - if (canceled) return; - room.addFurniture(id); - this.changed = true; - }, - - remove() { - this.isTranslateMode = false; - this.isRotateMode = false; - room.removeFurniture(); - this.changed = true; - }, - - save() { - this.$root.api('room/update', { - room: room.getRoomInfo() - }).then(() => { - this.changed = false; - this.$root.dialog({ - type: 'success', - text: this.$t('saved') - }); - }).catch((e: any) => { - this.$root.dialog({ - type: 'error', - text: e.message - }); - }); - }, - - clear() { - this.$root.dialog({ - type: 'warning', - text: this.$t('clear-confirm'), - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) return; - room.removeAllFurnitures(); - this.changed = true; - }); - }, - - chooseImage(key) { - this.$chooseDriveFile({ - multiple: false - }).then(file => { - room.updateProp(key, `/proxy/?${urlQuery({ url: file.thumbnailUrl })}`); - this.$refs.preview.selected(room.getSelectedObject()); - this.changed = true; - }); - }, - - updateColor(key, ev) { - room.updateProp(key, ev.target.value); - this.$refs.preview.selected(room.getSelectedObject()); - this.changed = true; - }, - - updateCarpetColor(ev) { - room.updateCarpetColor(ev.target.value); - this.carpetColor = ev.target.value; - this.changed = true; - }, - - updateRoomType(type) { - room.changeRoomType(type); - this.roomType = type; - this.changed = true; - }, - - translate() { - if (this.isTranslateMode) { - this.exit(); - } else { - this.isRotateMode = false; - this.isTranslateMode = true; - room.enterTransformMode('translate'); - } - this.changed = true; - }, - - rotate() { - if (this.isRotateMode) { - this.exit(); - } else { - this.isTranslateMode = false; - this.isRotateMode = true; - room.enterTransformMode('rotate'); - } - this.changed = true; - }, - - exit() { - this.isTranslateMode = false; - this.isRotateMode = false; - room.exitTransformMode(); - this.changed = true; - } - } -}); -</script> - -<style lang="stylus" scoped> -.hveuntkp - > .controller - > .menu - position fixed - z-index 1 - padding 16px - background var(--face) - color var(--text) - - > section - padding 16px 0 - - &:first-child - padding-top 0 - - &:last-child - padding-bottom 0 - - &:not(:last-child) - border-bottom solid 1px var(--faceDivider) - - > .controller - top 16px - left 16px - width 256px - - > section - > .name - margin 0 - - > .menu - top 16px - right 16px - width 256px - -</style> diff --git a/src/client/app/common/views/pages/share.vue b/src/client/app/common/views/pages/share.vue deleted file mode 100644 index 293a9bcfb5..0000000000 --- a/src/client/app/common/views/pages/share.vue +++ /dev/null @@ -1,79 +0,0 @@ -<template> -<div class="azibmfpleajagva420swmu4c3r7ni7iw"> - <h1>{{ $t('share-with', { name }) }}</h1> - <div> - <mk-signin v-if="!$store.getters.isSignedIn"/> - <x-post-form v-else-if="!posted" :initial-text="template" :instant="true" @posted="posted = true"/> - <p v-if="posted" class="posted"><fa icon="check"/></p> - </div> - <ui-button class="close" v-if="posted" @click="close">{{ $t('@.close') }}</ui-button> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/share.vue'), - components: { - XPostForm: () => import('../../../desktop/views/components/post-form.vue').then(m => m.default) - }, - data() { - return { - name: null, - posted: false, - text: new URLSearchParams(location.search).get('text'), - url: new URLSearchParams(location.search).get('url'), - title: new URLSearchParams(location.search).get('title'), - }; - }, - computed: { - template(): string { - let t = ''; - if (this.title && this.url) t += `【[${this.title}](${this.url})】\n`; - if (this.title && !this.url) t += `【${this.title}】\n`; - if (this.text) t += `${this.text}\n`; - if (!this.title && this.url) t += `${this.url}`; - return t.trim(); - } - }, - mounted() { - this.$root.getMeta().then(meta => { - this.name = meta.name || 'Misskey'; - }); - }, - methods: { - close() { - window.close(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.azibmfpleajagva420swmu4c3r7ni7iw - > h1 - margin 8px 0 - color #555 - font-size 20px - text-align center - - > div - max-width 500px - margin 0 auto - - > .posted - display block - margin 0 auto - padding 64px - text-align center - background #fff - border-radius 6px - width calc(100% - 32px) - - > .close - display block - margin 16px auto - width calc(100% - 32px) -</style> diff --git a/src/client/app/common/views/pages/user-group-editor.vue b/src/client/app/common/views/pages/user-group-editor.vue deleted file mode 100644 index 9cc012af7a..0000000000 --- a/src/client/app/common/views/pages/user-group-editor.vue +++ /dev/null @@ -1,256 +0,0 @@ -<template> -<div class="ivrbakop"> - <ui-container v-if="group"> - <template #header><fa :icon="faUsers"/> {{ group.name }}</template> - - <section> - <ui-margin> - <ui-button @click="rename"><fa :icon="faICursor"/> {{ $t('rename') }}</ui-button> - <ui-button @click="del"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</ui-button> - <ui-button @click="transfer"><fa :icon="faCrown"/> {{ $t('transfer') }}</ui-button> - </ui-margin> - </section> - </ui-container> - - <ui-container> - <template #header><fa :icon="faUsers"/> {{ $t('users') }}</template> - - <section> - <ui-margin> - <ui-button @click="invite()"><fa :icon="faPlus"/> {{ $t('invite') }}</ui-button> - </ui-margin> - <sequential-entrance animation="entranceFromTop" delay="25"> - <div class="kjlrfbes" v-for="user in users"> - <div> - <a :href="user | userPage"> - <mk-avatar class="avatar" :user="user" :disable-link="true"/> - </a> - </div> - <div> - <header> - <b><mk-user-name :user="user"/></b> - <span class="is-owner" v-if="group.ownerId === user.id">owner</span> - <span class="username">@{{ user | acct }}</span> - </header> - <div v-if="group.ownerId !== user.id"> - <a @click="remove(user)">{{ $t('remove-user') }}</a> - </div> - </div> - </div> - </sequential-entrance> - </section> - </ui-container> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { faCrown, faICursor, faUsers, faPlus } from '@fortawesome/free-solid-svg-icons'; -import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; - -export default Vue.extend({ - i18n: i18n('common/views/components/user-group-editor.vue'), - - props: { - groupId: { - required: true - } - }, - - data() { - return { - group: null, - users: [], - faCrown, faICursor, faTrashAlt, faUsers, faPlus - }; - }, - - created() { - this.$root.api('users/groups/show', { - groupId: this.groupId - }).then(group => { - this.group = group; - this.fetchUsers(); - this.$emit('init', { - title: this.group.name, - icon: faUsers - }); - }); - }, - - methods: { - fetchGroup() { - this.$root.api('users/groups/show', { - groupId: this.group.id - }).then(group => { - this.group = group; - }) - }, - - fetchUsers() { - this.$root.api('users/show', { - userIds: this.group.userIds - }).then(users => { - this.users = users; - }); - }, - - rename() { - this.$root.dialog({ - title: this.$t('rename'), - input: { - default: this.group.name - } - }).then(({ canceled, result: name }) => { - if (canceled) return; - this.$root.api('users/groups/update', { - groupId: this.group.id, - name: name - }).then(() => { - this.fetchGroup(); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - }) - }, - - del() { - this.$root.dialog({ - type: 'warning', - text: this.$t('delete-are-you-sure').replace('$1', this.group.name), - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) return; - - this.$root.api('users/groups/delete', { - groupId: this.group.id - }).then(() => { - this.$root.dialog({ - type: 'success', - text: this.$t('deleted') - }); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - }); - }, - - remove(user: any) { - this.$root.api('users/groups/pull', { - groupId: this.group.id, - userId: user.id - }).then(() => { - this.fetchGroup(); - this.fetchUsers(); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - }, - - async invite() { - const t = this.$t('invited'); - const { result: user } = await this.$root.dialog({ - user: { - local: true - } - }); - if (user == null) return; - this.$root.api('users/groups/invite', { - groupId: this.group.id, - userId: user.id - }).then(() => { - this.$root.dialog({ - type: 'success', - text: t - }); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - }, - - async transfer() { - const { result: user } = await this.$root.dialog({ - user: { - local: true - } - }); - if (user == null) return; - - this.$root.dialog({ - type: 'warning', - text: this.$t('transfer-are-you-sure').replace('$1', this.group.name).replace('$2', user.username), - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) return; - - this.$root.api('users/groups/transfer', { - groupId: this.group.id, - userId: user.id - }).then(() => { - this.$root.dialog({ - type: 'success', - text: this.$t('transferred') - }); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.ivrbakop - .kjlrfbes - display flex - padding 16px - border-top solid 1px var(--faceDivider) - - > div:first-child - > a - > .avatar - width 64px - height 64px - - > div:last-child - flex 1 - padding-left 16px - - @media (max-width 500px) - font-size 14px - - > header - color var(--text) - - > .is-owner - flex-shrink 0 - align-self center - margin-left 8px - padding 1px 6px - font-size 80% - background var(--groupUserListOwnerBg) - color var(--groupUserListOwnerFg) - border-radius 3px - - > .username - margin-left 8px - opacity 0.7 - -</style> diff --git a/src/client/app/common/views/pages/user-groups.vue b/src/client/app/common/views/pages/user-groups.vue deleted file mode 100644 index 6501a26061..0000000000 --- a/src/client/app/common/views/pages/user-groups.vue +++ /dev/null @@ -1,132 +0,0 @@ -<template> -<div> - <ui-container :body-togglable="true"> - <template #header><fa :icon="faUsers"/> {{ $t('owned-groups') }}</template> - <ui-margin> - <ui-button @click="add"><fa :icon="faPlus"/> {{ $t('create-group') }}</ui-button> - </ui-margin> - <div class="hwgkdrbl" v-for="group in ownedGroups" :key="group.id"> - <ui-hr/> - <ui-margin> - <router-link :to="`/i/groups/${group.id}`">{{ group.name }}</router-link> - <x-avatars :user-ids="group.userIds" style="margin-top:8px;"/> - </ui-margin> - </div> - </ui-container> - - <ui-container :body-togglable="true"> - <template #header><fa :icon="faUsers"/> {{ $t('joined-groups') }}</template> - <div class="hwgkdrbl" v-for="(group, i) in joinedGroups" :key="group.id"> - <ui-hr v-if="i != 0"/> - <ui-margin> - <div style="color:var(--text);">{{ group.name }}</div> - <x-avatars :user-ids="group.userIds" style="margin-top:8px;"/> - </ui-margin> - </div> - </ui-container> - - <ui-container :body-togglable="true"> - <template #header><fa :icon="faEnvelopeOpenText"/> {{ $t('invites') }}</template> - <div class="fvlojuur" v-for="(invite, i) in invites" :key="invite.id"> - <ui-hr v-if="i != 0"/> - <ui-margin> - <div class="name" style="color:var(--text);">{{ invite.group.name }}</div> - <x-avatars :user-ids="invite.group.userIds" style="margin-top:8px;"/> - <ui-horizon-group> - <ui-button @click="acceptInvite(invite)"><fa :icon="faCheck"/> {{ $t('accept-invite') }}</ui-button> - <ui-button @click="rejectInvite(invite)"><fa :icon="faBan"/> {{ $t('reject-invite') }}</ui-button> - </ui-horizon-group> - </ui-margin> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faUsers, faPlus, faCheck, faBan, faEnvelopeOpenText } from '@fortawesome/free-solid-svg-icons'; -import i18n from '../../../i18n'; -import XAvatars from '../../views/components/avatars.vue'; - -export default Vue.extend({ - i18n: i18n('common/views/components/user-groups.vue'), - components: { - XAvatars - }, - data() { - return { - ownedGroups: [], - joinedGroups: [], - invites: [], - faUsers, faPlus, faCheck, faBan, faEnvelopeOpenText - }; - }, - mounted() { - document.title = this.$root.instanceName; - - this.$root.api('users/groups/owned').then(groups => { - this.ownedGroups = groups; - }); - - this.$root.api('users/groups/joined').then(groups => { - this.joinedGroups = groups; - }); - - this.$root.api('i/user-group-invites').then(invites => { - this.invites = invites; - }); - - this.$emit('init', { - title: this.$t('user-groups'), - icon: faUsers - }); - }, - methods: { - add() { - this.$root.dialog({ - title: this.$t('group-name'), - input: true - }).then(async ({ canceled, result: name }) => { - if (canceled) return; - const group = await this.$root.api('users/groups/create', { - name - }); - - this.ownedGroups.push(group) - }); - }, - acceptInvite(invite) { - this.$root.api('users/groups/invitations/accept', { - inviteId: invite.id - }).then(() => { - this.$root.dialog({ - type: 'success', - splash: true - }); - this.$root.api('i/user-group-invites').then(invites => { - this.invites = invites; - }).then(() => { - this.$root.api('users/groups/joined').then(groups => { - this.joinedGroups = groups; - }); - }); - }); - }, - rejectInvite(invite) { - this.$root.api('users/groups/invitations/reject', { - inviteId: invite.id - }).then(() => { - this.$root.api('i/user-group-invites').then(invites => { - this.invites = invites; - }); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.hwgkdrbl - display block - -</style> diff --git a/src/client/app/common/views/pages/user-list-editor.vue b/src/client/app/common/views/pages/user-list-editor.vue deleted file mode 100644 index 3bc5cca778..0000000000 --- a/src/client/app/common/views/pages/user-list-editor.vue +++ /dev/null @@ -1,182 +0,0 @@ -<template> -<div class="cudqjmnl"> - <ui-container v-if="list"> - <template #header><fa :icon="faListUl"/> {{ list.name }}</template> - - <section class="fwvevrks"> - <ui-margin> - <ui-button @click="rename"><fa :icon="faICursor"/> {{ $t('rename') }}</ui-button> - <ui-button @click="del"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</ui-button> - </ui-margin> - </section> - </ui-container> - - <ui-container> - <template #header><fa :icon="faUsers"/> {{ $t('users') }}</template> - - <section> - <ui-margin> - <ui-button @click="add"><fa :icon="faPlus"/> {{ $t('add-user') }}</ui-button> - </ui-margin> - <sequential-entrance animation="entranceFromTop" delay="25"> - <div class="phcqulfl" v-for="user in users"> - <div> - <a :href="user | userPage"> - <mk-avatar class="avatar" :user="user" :disable-link="true"/> - </a> - </div> - <div> - <header> - <b><mk-user-name :user="user"/></b> - <span class="username">@{{ user | acct }}</span> - </header> - <div> - <a @click="remove(user)">{{ $t('remove-user') }}</a> - </div> - </div> - </div> - </sequential-entrance> - </section> - </ui-container> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { faListUl, faICursor, faUsers, faPlus } from '@fortawesome/free-solid-svg-icons'; -import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; - -export default Vue.extend({ - i18n: i18n('common/views/components/user-list-editor.vue'), - - props: { - listId: { - required: true - } - }, - - data() { - return { - list: null, - users: [], - faListUl, faICursor, faTrashAlt, faUsers, faPlus - }; - }, - - created() { - this.$root.api('users/lists/show', { - listId: this.listId - }).then(list => { - this.list = list; - this.fetchUsers(); - this.$emit('init', { - title: this.list.name, - icon: faListUl - }); - }); - }, - - methods: { - fetchUsers() { - this.$root.api('users/show', { - userIds: this.list.userIds - }).then(users => { - this.users = users; - }); - }, - - rename() { - this.$root.dialog({ - title: this.$t('rename'), - input: { - default: this.list.name - } - }).then(({ canceled, result: name }) => { - if (canceled) return; - this.$root.api('users/lists/update', { - listId: this.list.id, - name: name - }); - }); - }, - - del() { - this.$root.dialog({ - type: 'warning', - text: this.$t('delete-are-you-sure').replace('$1', this.list.name), - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) return; - - this.$root.api('users/lists/delete', { - listId: this.list.id - }).then(() => { - this.$root.dialog({ - type: 'success', - text: this.$t('deleted') - }); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); - }); - }); - }, - - remove(user: any) { - this.$root.api('users/lists/pull', { - listId: this.list.id, - userId: user.id - }).then(() => { - this.fetchUsers(); - }); - }, - - async add() { - const { result: user } = await this.$root.dialog({ - user: { - local: true - } - }); - if (user == null) return; - this.$root.api('users/lists/push', { - listId: this.list.id, - userId: user.id - }).then(() => { - this.fetchUsers(); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.cudqjmnl - .phcqulfl - display flex - padding 16px - border-top solid 1px var(--faceDivider) - - > div:first-child - > a - > .avatar - width 64px - height 64px - - > div:last-child - flex 1 - padding-left 16px - - @media (max-width 500px) - font-size 14px - - > header - color var(--text) - - > .username - margin-left 8px - opacity 0.7 - -</style> diff --git a/src/client/app/common/views/pages/user-lists.vue b/src/client/app/common/views/pages/user-lists.vue deleted file mode 100644 index 955eef993a..0000000000 --- a/src/client/app/common/views/pages/user-lists.vue +++ /dev/null @@ -1,70 +0,0 @@ -<template> -<ui-container> - <template #header><fa :icon="faListUl"/> {{ $t('user-lists') }}</template> - <ui-margin> - <ui-button @click="add"><fa :icon="faPlus"/> {{ $t('create-list') }}</ui-button> - </ui-margin> - <div class="cpqqyrst" v-for="list in lists" :key="list.id"> - <ui-hr/> - <ui-margin> - <router-link :to="`/i/lists/${list.id}`">{{ list.name }}</router-link> - <x-avatars :user-ids="list.userIds" style="margin-top:8px;"/> - </ui-margin> - </div> -</ui-container> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { faListUl, faPlus } from '@fortawesome/free-solid-svg-icons'; -import XAvatars from '../../views/components/avatars.vue'; - -export default Vue.extend({ - i18n: i18n('common/views/components/user-lists.vue'), - components: { - XAvatars - }, - data() { - return { - fetching: true, - lists: [], - faListUl, faPlus - }; - }, - mounted() { - document.title = this.$root.instanceName; - - this.$root.api('users/lists/list').then(lists => { - this.fetching = false; - this.lists = lists; - }); - - this.$emit('init', { - title: this.$t('user-lists'), - icon: faListUl - }); - }, - methods: { - add() { - this.$root.dialog({ - title: this.$t('list-name'), - input: true - }).then(async ({ canceled, result: name }) => { - if (canceled) return; - const list = await this.$root.api('users/lists/create', { - name - }); - - this.lists.push(list) - }); - }, - } -}); -</script> - -<style lang="stylus" scoped> -.cpqqyrst - display block - -</style> diff --git a/src/client/app/common/views/widgets/analog-clock.vue b/src/client/app/common/views/widgets/analog-clock.vue deleted file mode 100644 index bff01f89b5..0000000000 --- a/src/client/app/common/views/widgets/analog-clock.vue +++ /dev/null @@ -1,33 +0,0 @@ -<template> -<div class="mkw-analog-clock"> - <ui-container :naked="props.style % 2 === 0" :show-header="false"> - <div class="mkw-analog-clock--body"> - <mk-analog-clock :dark="$store.state.device.darkmode" :smooth="props.style < 2"/> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -export default define({ - name: 'analog-clock', - props: () => ({ - style: 0 - }) -}).extend({ - methods: { - func() { - this.props.style = (this.props.style + 1) % 4; - this.save(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mkw-analog-clock - .mkw-analog-clock--body - padding 8px - -</style> diff --git a/src/client/app/common/views/widgets/broadcast.vue b/src/client/app/common/views/widgets/broadcast.vue deleted file mode 100644 index 9423d25da8..0000000000 --- a/src/client/app/common/views/widgets/broadcast.vue +++ /dev/null @@ -1,205 +0,0 @@ -<template> -<div class="anltbovirfeutcigvwgmgxipejaeozxi"> - <ui-container :show-header="false" :naked="props.design === 1"> - <div class="anltbovirfeutcigvwgmgxipejaeozxi-body" - :data-found="announcements && announcements.length !== 0" - :data-melt="props.design == 1" - :data-mobile="platform == 'mobile'" - > - <div class="broadcast-left" v-show="announcements && announcements.length !== 0"> - <div class="icon"> - <svg height="32" version="1.1" viewBox="0 0 32 32" width="32"> - <path class="tower" d="M16.04,11.24c1.79,0,3.239-1.45,3.239-3.24S17.83,4.76,16.04,4.76c-1.79,0-3.24,1.45-3.24,3.24 C12.78,9.78,14.24,11.24,16.04,11.24z M16.04,13.84c-0.82,0-1.66-0.2-2.4-0.6L7.34,29.98h2.98l1.72-2h8l1.681,2H24.7L18.42,13.24 C17.66,13.64,16.859,13.84,16.04,13.84z M16.02,14.8l2.02,7.2h-4L16.02,14.8z M12.04,25.98l2-2h4l2,2H12.04z"></path> - <path class="wave a" d="M4.66,1.04c-0.508-0.508-1.332-0.508-1.84,0c-1.86,1.92-2.8,4.44-2.8,6.94c0,2.52,0.94,5.04,2.8,6.96 c0.5,0.52,1.32,0.52,1.82,0s0.5-1.36,0-1.88C3.28,11.66,2.6,9.82,2.6,7.98S3.28,4.3,4.64,2.9C5.157,2.391,5.166,1.56,4.66,1.04z"></path> - <path class="wave b" d="M9.58,12.22c0.5-0.5,0.5-1.34,0-1.84C8.94,9.72,8.62,8.86,8.62,8s0.32-1.72,0.96-2.38c0.5-0.52,0.5-1.34,0-1.84 C9.346,3.534,9.02,3.396,8.68,3.4c-0.32,0-0.66,0.12-0.9,0.38C6.64,4.94,6.08,6.48,6.08,8s0.58,3.06,1.7,4.22 C8.28,12.72,9.1,12.72,9.58,12.22z"></path> - <path class="wave c" d="M22.42,3.78c-0.5,0.5-0.5,1.34,0,1.84c0.641,0.66,0.96,1.52,0.96,2.38s-0.319,1.72-0.96,2.38c-0.5,0.52-0.5,1.34,0,1.84 c0.487,0.497,1.285,0.505,1.781,0.018c0.007-0.006,0.013-0.012,0.02-0.018c1.139-1.16,1.699-2.7,1.699-4.22s-0.561-3.06-1.699-4.22 c-0.494-0.497-1.297-0.5-1.794-0.007C22.424,3.775,22.422,3.778,22.42,3.78z"></path> - <path class="wave d" d="M29.18,1.06c-0.479-0.502-1.273-0.522-1.775-0.044c-0.016,0.015-0.029,0.029-0.045,0.044c-0.5,0.52-0.5,1.36,0,1.88 c1.361,1.4,2.041,3.24,2.041,5.08s-0.68,3.66-2.041,5.08c-0.5,0.52-0.5,1.36,0,1.88c0.509,0.508,1.332,0.508,1.841,0 c1.86-1.92,2.8-4.44,2.8-6.96C31.99,5.424,30.98,2.931,29.18,1.06z"></path> - </svg> - </div> - <div class="broadcast-nav" v-show="announcements && announcements.length > 1"> - <mk-frac class="broadcast-page" :value="i + 1" :total="announcements.length"/> - <ui-button class="broadcast-prev" @click="prev" :title="$t('next')"><fa :icon="faAngleLeft"/></ui-button> - <ui-button class="broadcast-next" @click="next" :title="$t('prev')"><fa :icon="faAngleRight"/></ui-button> - </div> - </div> - <div class="broadcast-right"> - <p class="fetching" v-if="fetching">{{ $t('fetching') }}<mk-ellipsis/></p> - <h1 v-if="!fetching">{{ announcements.length == 0 ? $t('no-broadcasts') : announcements[i].title }}</h1> - <p v-if="!fetching"> - <mfm v-if="announcements.length != 0" :text="announcements[i].text" :key="i"/> - <img v-if="announcements.length != 0 && announcements[i].image" :src="announcements[i].image" alt="" style="display: block; max-height: 130px; max-width: 100%;"/> - <template v-if="announcements.length == 0">{{ $t('have-a-nice-day') }}</template> - </p> - </div> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import { faAngleLeft, faAngleRight } from '@fortawesome/free-solid-svg-icons'; -import i18n from '../../../i18n'; - -export default define({ - name: 'broadcast', - props: () => ({ - design: 0 - }) -}).extend({ - i18n: i18n('common/views/widgets/broadcast.vue'), - data() { - return { - i: 0, - fetching: true, - announcements: [], - faAngleLeft, faAngleRight - }; - }, - mounted() { - this.$root.getMeta().then(meta => { - this.announcements = meta.announcements; - this.fetching = false; - }); - }, - methods: { - next() { - if (this.i === this.announcements.length - 1) { - this.i = 0; - } else { - this.i++; - } - }, - prev() { - if (this.i === 0) { - this.i = this.announcements.length - 1; - } else { - this.i--; - } - }, - func() { - if (this.props.design === 1) { - this.props.design = 0; - } else { - this.props.design++; - } - this.save(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.anltbovirfeutcigvwgmgxipejaeozxi-body - display flex - padding 10px - background var(--announcementsBg) - - &[data-melt] - background transparent - - > .broadcast-left - width 32px - margin-right 8px - - > .icon - > svg - fill currentColor - color var(--announcementsTitle) - - > .wave - opacity 1 - - &.a - animation wave 20s ease-in-out 2.1s infinite - &.b - animation wave 20s ease-in-out 2s infinite - &.c - animation wave 20s ease-in-out 2s infinite - &.d - animation wave 20s ease-in-out 2.1s infinite - - @keyframes wave - 0% - opacity 1 - 1.5% - opacity 0 - 3.5% - opacity 0 - 5% - opacity 1 - 6.5% - opacity 0 - 8.5% - opacity 0 - 10% - opacity 1 - - > .broadcast-nav - display flex - flex-wrap wrap - padding 1px 0 2px - - > .broadcast-page - width 100% - color var(--announcementsTitle) - text-align center - font-size .6rem - - > .broadcast-prev, - > .broadcast-next - flex 1 - width 50% - display block - margin 0 - padding 0 - font-size .9rem - line-height 1.3em - color var(--link) - background transparent - cursor pointer - - &:focus - &:after - top -1px - right -1px - bottom -1px - left -1px - - &.round:focus:after - border-radius 5px - - > .broadcast-prev - padding-right 3px - - > .broadcast-next - padding-left 3px - - > .broadcast-right - flex 1 - word-break break-word - - > h1 - margin 0 - font-size .975em - font-weight normal - line-height 1.3em - color var(--announcementsTitle) - padding-bottom 2px - - > p - display block - z-index 1 - margin 0 - font-size .8em - color var(--announcementsText) - width 100% - - &.fetching - text-align center - - &[data-mobile] - > p - color #fff - -</style> diff --git a/src/client/app/common/views/widgets/calendar.vue b/src/client/app/common/views/widgets/calendar.vue deleted file mode 100644 index 32ce1efeb7..0000000000 --- a/src/client/app/common/views/widgets/calendar.vue +++ /dev/null @@ -1,202 +0,0 @@ -<template> -<div class="mkw-calendar" :data-special="special" :data-mobile="platform == 'mobile'"> - <ui-container :naked="props.design == 1" :show-header="false"> - <div class="mkw-calendar--body"> - <div class="calendar" :data-is-holiday="isHoliday"> - <p class="month-and-year"> - <span class="year">{{ this.$t('year').split('{}')[0] }}{{ year }}{{ this.$t('year').split('{}')[1] }}</span> - <span class="month">{{ this.$t('month').split('{}')[0] }}{{ month }}{{ this.$t('month').split('{}')[1] }}</span> - </p> - <p class="day">{{ this.$t('day').split('{}')[0] }}{{ day }}{{ this.$t('day').split('{}')[1] }}</p> - <p class="week-day">{{ weekDay }}</p> - </div> - <div class="info"> - <div> - <p>{{ $t('today') }}<b>{{ dayP.toFixed(1) }}%</b></p> - <div class="meter"> - <div class="val" :style="{ width: `${dayP}%` }"></div> - </div> - </div> - <div> - <p>{{ $t('this-month') }}<b>{{ monthP.toFixed(1) }}%</b></p> - <div class="meter"> - <div class="val" :style="{ width: `${monthP}%` }"></div> - </div> - </div> - <div> - <p>{{ $t('this-year') }}<b>{{ yearP.toFixed(1) }}%</b></p> - <div class="meter"> - <div class="val" :style="{ width: `${yearP}%` }"></div> - </div> - </div> - </div> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; - -export default define({ - name: 'calendar', - props: () => ({ - design: 0 - }) -}).extend({ - i18n: i18n('common/views/widgets/calendar.vue'), - data() { - return { - now: new Date(), - year: null, - month: null, - day: null, - weekDay: null, - yearP: null, - dayP: null, - monthP: null, - isHoliday: null, - special: null, - clock: null - }; - }, - created() { - this.tick(); - this.clock = setInterval(this.tick, 1000); - }, - beforeDestroy() { - clearInterval(this.clock); - }, - methods: { - func() { - if (this.platform == 'mobile') return; - if (this.props.design == 2) { - this.props.design = 0; - } else { - this.props.design++; - } - this.save(); - }, - tick() { - const now = new Date(); - const nd = now.getDate(); - const nm = now.getMonth(); - const ny = now.getFullYear(); - - this.year = ny; - this.month = nm + 1; - this.day = nd; - this.weekDay = [ - this.$t('@.weekday.sunday'), - this.$t('@.weekday.monday'), - this.$t('@.weekday.tuesday'), - this.$t('@.weekday.wednesday'), - this.$t('@.weekday.thursday'), - this.$t('@.weekday.friday'), - this.$t('@.weekday.saturday') - ][now.getDay()]; - - const dayNumer = now.getTime() - new Date(ny, nm, nd).getTime(); - const dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/; - const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime(); - const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime(); - const yearNumer = now.getTime() - new Date(ny, 0, 1).getTime(); - const yearDenom = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime(); - - this.dayP = dayNumer / dayDenom * 100; - this.monthP = monthNumer / monthDenom * 100; - this.yearP = yearNumer / yearDenom * 100; - - this.isHoliday = now.getDay() == 0 || now.getDay() == 6; - - this.special = - nm == 0 && nd == 1 ? 'on-new-years-day' : - false; - } - } -}); -</script> - -<style lang="stylus" scoped> -.mkw-calendar - &[data-special='on-new-years-day'] - border-color #ef95a0 - - .mkw-calendar--body - padding 16px 0 - color var(--calendarDay) - - &:after - content "" - display block - clear both - - > .calendar - float left - width 60% - text-align center - - &[data-is-holiday] - > .day - color #ef95a0 - - > p - margin 0 - line-height 18px - font-size 14px - - > span - margin 0 4px - - > .day - margin 10px 0 - line-height 32px - font-size 28px - - > .info - display block - float left - width 40% - padding 0 16px 0 0 - - > div - margin-bottom 8px - - &:last-child - margin-bottom 4px - - > p - margin 0 0 2px 0 - font-size 12px - line-height 18px - color var(--text) - opacity 0.8 - - > b - margin-left 2px - - > .meter - width 100% - overflow hidden - background var(--materBg) - border-radius 8px - - > .val - height 4px - background var(--primary) - transition width .3s cubic-bezier(0.23, 1, 0.32, 1) - - &:nth-child(1) - > .meter > .val - background #f7796c - - &:nth-child(2) - > .meter > .val - background #a1de41 - - &:nth-child(3) - > .meter > .val - background #41ddde - -</style> diff --git a/src/client/app/common/views/widgets/hashtags.vue b/src/client/app/common/views/widgets/hashtags.vue deleted file mode 100644 index b266d5f6e6..0000000000 --- a/src/client/app/common/views/widgets/hashtags.vue +++ /dev/null @@ -1,31 +0,0 @@ -<template> -<div class="mkw-hashtags"> - <ui-container :show-header="!props.compact"> - <template #header><fa icon="hashtag"/>{{ $t('title') }}</template> - - <div class="mkw-hashtags--body" :data-mobile="platform == 'mobile'"> - <mk-trends/> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; - -export default define({ - name: 'hashtags', - props: () => ({ - compact: false - }) -}).extend({ - i18n: i18n('common/views/widgets/hashtags.vue'), - methods: { - func() { - this.props.compact = !this.props.compact; - this.save(); - } - } -}); -</script> diff --git a/src/client/app/common/views/widgets/index.ts b/src/client/app/common/views/widgets/index.ts deleted file mode 100644 index d923a01941..0000000000 --- a/src/client/app/common/views/widgets/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import Vue from 'vue'; - -import wAnalogClock from './analog-clock.vue'; -import wVersion from './version.vue'; -import wRss from './rss.vue'; -import wServer from './server.vue'; -import wPostsMonitor from './posts-monitor.vue'; -import wMemo from './memo.vue'; -import wBroadcast from './broadcast.vue'; -import wCalendar from './calendar.vue'; -import wPhotoStream from './photo-stream.vue'; -import wSlideshow from './slideshow.vue'; -import wTips from './tips.vue'; -import wNav from './nav.vue'; -import wHashtags from './hashtags.vue'; -import wInstance from './instance.vue'; -import wPostForm from './post-form.vue'; - -Vue.component('mkw-analog-clock', wAnalogClock); -Vue.component('mkw-nav', wNav); -Vue.component('mkw-calendar', wCalendar); -Vue.component('mkw-photo-stream', wPhotoStream); -Vue.component('mkw-slideshow', wSlideshow); -Vue.component('mkw-tips', wTips); -Vue.component('mkw-broadcast', wBroadcast); -Vue.component('mkw-server', wServer); -Vue.component('mkw-posts-monitor', wPostsMonitor); -Vue.component('mkw-memo', wMemo); -Vue.component('mkw-rss', wRss); -Vue.component('mkw-version', wVersion); -Vue.component('mkw-hashtags', wHashtags); -Vue.component('mkw-instance', wInstance); -Vue.component('mkw-post-form', wPostForm); -Vue.component('mkw-queue', () => import('./queue.vue').then(m => m.default)); diff --git a/src/client/app/common/views/widgets/instance.vue b/src/client/app/common/views/widgets/instance.vue deleted file mode 100644 index 96d6184d1e..0000000000 --- a/src/client/app/common/views/widgets/instance.vue +++ /dev/null @@ -1,14 +0,0 @@ -<template> -<div class="mkw-instance"> - <ui-container> - <mk-instance/> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -export default define({ - name: 'instance' -}); -</script> diff --git a/src/client/app/common/views/widgets/memo.vue b/src/client/app/common/views/widgets/memo.vue deleted file mode 100644 index b3b668a9ad..0000000000 --- a/src/client/app/common/views/widgets/memo.vue +++ /dev/null @@ -1,108 +0,0 @@ -<template> -<div class="mkw-memo"> - <ui-container :show-header="!props.compact"> - <template #header><fa :icon="['far', 'sticky-note']"/>{{ $t('title') }}</template> - - <div class="mkw-memo--body"> - <textarea v-model="text" :placeholder="$t('placeholder')" @input="onChange"></textarea> - <button @click="saveMemo" :disabled="!changed">{{ $t('save') }}</button> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../define-widget'; -import i18n from '../../../i18n'; - -export default define({ - name: 'memo', - props: () => ({ - compact: false - }) -}).extend({ - i18n: i18n('common/views/widgets/memo.vue'), - data() { - return { - text: null, - changed: false, - timeoutId: null - }; - }, - - created() { - this.text = this.$store.state.settings.memo; - - this.$watch('$store.state.settings.memo', text => { - this.text = text; - }); - }, - - methods: { - func() { - this.props.compact = !this.props.compact; - this.save(); - }, - - onChange() { - this.changed = true; - clearTimeout(this.timeoutId); - this.timeoutId = setTimeout(this.saveMemo, 1000); - }, - - saveMemo() { - this.$store.dispatch('settings/set', { - key: 'memo', - value: this.text - }); - this.changed = false; - } - } -}); -</script> - -<style lang="stylus" scoped> -.mkw-memo - .mkw-memo--body - padding-bottom 28px + 16px - - > textarea - display block - width 100% - max-width 100% - min-width 100% - padding 16px - color var(--inputText) - background var(--face) - border none - border-bottom solid var(--lineWidth) var(--faceDivider) - border-radius 0 - - > button - display block - position absolute - bottom 8px - right 8px - margin 0 - padding 0 10px - height 28px - color var(--primaryForeground) - background var(--primary) !important - outline none - border none - border-radius 4px - transition background 0.1s ease - cursor pointer - - &:hover - background var(--primaryLighten10) !important - - &:active - background var(--primaryDarken10) !important - transition background 0s ease - - &:disabled - opacity 0.7 - cursor default - -</style> diff --git a/src/client/app/common/views/widgets/nav.vue b/src/client/app/common/views/widgets/nav.vue deleted file mode 100644 index 2b8caa7be8..0000000000 --- a/src/client/app/common/views/widgets/nav.vue +++ /dev/null @@ -1,32 +0,0 @@ -<template> -<div class="mkw-nav"> - <ui-container> - <div class="mkw-nav--body"> - <mk-nav/> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -export default define({ - name: 'nav' -}); -</script> - -<style lang="stylus" scoped> -.mkw-nav - .mkw-nav--body - padding 16px - font-size 12px - color var(--text) - background var(--face) - - a - color var(--text) - - i - color var(--text) - -</style> diff --git a/src/client/app/common/views/widgets/photo-stream.vue b/src/client/app/common/views/widgets/photo-stream.vue deleted file mode 100644 index eae6d0a190..0000000000 --- a/src/client/app/common/views/widgets/photo-stream.vue +++ /dev/null @@ -1,125 +0,0 @@ -<template> -<div class="mkw-photo-stream" :class="$style.root" :data-melt="props.design == 2"> - <ui-container :show-header="props.design == 0" :naked="props.design == 2"> - <template #header><fa icon="camera"/>{{ $t('title') }}</template> - - <p :class="$style.fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p> - <div :class="$style.stream" v-if="!fetching && images.length > 0"> - <div v-for="(image, i) in images" :key="i" - :class="$style.img" - :style="`background-image: url(${thumbnail(image)})`" - draggable="true" - @dragstart="onDragstart(image, $event)" - ></div> - </div> - <p :class="$style.empty" v-if="!fetching && images.length == 0">{{ $t('no-photos') }}</p> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; -import { getStaticImageUrl } from '../../scripts/get-static-image-url'; - -export default define({ - name: 'photo-stream', - props: () => ({ - design: 0 - }) -}).extend({ - i18n: i18n('common/views/widgets/photo-stream.vue'), - - data() { - return { - images: [], - fetching: true, - connection: null - }; - }, - - mounted() { - this.connection = this.$root.stream.useSharedConnection('main'); - - this.connection.on('driveFileCreated', this.onDriveFileCreated); - - this.$root.api('drive/stream', { - type: 'image/*', - limit: 9 - }).then(images => { - this.images = images; - this.fetching = false; - }); - }, - - beforeDestroy() { - this.connection.dispose(); - }, - - methods: { - onDriveFileCreated(file) { - if (/^image\/.+$/.test(file.type)) { - this.images.unshift(file); - if (this.images.length > 9) this.images.pop(); - } - }, - - func() { - if (this.props.design == 2) { - this.props.design = 0; - } else { - this.props.design++; - } - - this.save(); - }, - - onDragstart(file, e) { - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('mk_drive_file', JSON.stringify(file)); - }, - - thumbnail(image: any): string { - return this.$store.state.device.disableShowingAnimatedImages - ? getStaticImageUrl(image.thumbnailUrl) - : image.thumbnailUrl; - }, - } -}); -</script> - -<style lang="stylus" module> -.root[data-melt] - .stream - padding 0 - - .img - border solid 4px transparent - border-radius 8px - -.stream - display flex - justify-content center - flex-wrap wrap - padding 8px - - .img - flex 1 1 33% - width 33% - height 80px - background-position center center - background-size cover - border solid 2px transparent - border-radius 4px - -.fetching -.empty - margin 0 - padding 16px - text-align center - color var(--text) - - > [data-icon] - margin-right 4px - -</style> diff --git a/src/client/app/common/views/widgets/post-form.vue b/src/client/app/common/views/widgets/post-form.vue deleted file mode 100644 index 6680a11435..0000000000 --- a/src/client/app/common/views/widgets/post-form.vue +++ /dev/null @@ -1,294 +0,0 @@ -<template> -<div> - <ui-container :show-header="props.design == 0"> - <template #header><fa icon="pencil-alt"/>{{ $t('title') }}</template> - - <div class="lhcuptdmcdkfwmipgazeawoiuxpzaclc-body" - @dragover.stop="onDragover" - @drop.stop="onDrop" - > - <div class="textarea"> - <textarea - :disabled="posting" - v-model="text" - @keydown="onKeydown" - @paste="onPaste" - :placeholder="placeholder" - ref="text" - v-autocomplete="{ model: 'text' }" - ></textarea> - <button class="emoji" @click="emoji" ref="emoji" v-if="!$root.isMobile"> - <fa :icon="['far', 'laugh']"/> - </button> - </div> - <x-post-form-attaches class="files" :files="files" :detach-media-fn="detachMedia"/> - <input ref="file" type="file" multiple="multiple" tabindex="-1" @change="onChangeFile"/> - <mk-uploader ref="uploader" @uploaded="attachMedia"/> - <footer> - <button @click="chooseFile"><fa icon="upload"/></button> - <button @click="chooseFileFromDrive"><fa icon="cloud"/></button> - <button @click="post" :disabled="posting" class="post">{{ $t('note') }}</button> - </footer> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; -import insertTextAtCursor from 'insert-text-at-cursor'; -import { formatTimeString } from '../../../../../misc/format-time-string'; - -export default define({ - name: 'post-form', - props: () => ({ - design: 0 - }) -}).extend({ - i18n: i18n('desktop/views/widgets/post-form.vue'), - - components: { - XPostFormAttaches: () => import('../components/post-form-attaches.vue').then(m => m.default) - }, - - data() { - return { - posting: false, - text: '', - files: [], - }; - }, - - computed: { - placeholder(): string { - const xs = [ - this.$t('@.note-placeholders.a'), - this.$t('@.note-placeholders.b'), - this.$t('@.note-placeholders.c'), - this.$t('@.note-placeholders.d'), - this.$t('@.note-placeholders.e'), - this.$t('@.note-placeholders.f') - ]; - return xs[Math.floor(Math.random() * xs.length)]; - } - }, - - methods: { - func() { - if (this.props.design == 1) { - this.props.design = 0; - } else { - this.props.design++; - } - this.save(); - }, - - chooseFile() { - (this.$refs.file as any).click(); - }, - - chooseFileFromDrive() { - this.$chooseDriveFile({ - multiple: true - }).then(files => { - for (const x of files) this.attachMedia(x); - }); - }, - - attachMedia(driveFile) { - this.files.push(driveFile); - this.$emit('change-attached-files', this.files); - }, - - detachMedia(id) { - this.files = this.files.filter(x => x.id != id); - this.$emit('change-attached-files', this.files); - }, - - onKeydown(e) { - if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && !this.posting && this.text) this.post(); - }, - - async onPaste(e: ClipboardEvent) { - for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) { - if (item.kind == 'file') { - const file = item.getAsFile(); - const lio = file.name.lastIndexOf('.'); - const ext = lio >= 0 ? file.name.slice(lio) : ''; - const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.settings.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; - const name = this.$store.state.settings.pasteDialog - ? await this.$root.dialog({ - title: this.$t('@.post-form.enter-file-name'), - input: { - default: formatted - }, - allowEmpty: false - }).then(({ canceled, result }) => canceled ? false : result) - : formatted; - if (name) this.upload(file, name); - } - } - }, - - onChangeFile() { - for (const x of Array.from((this.$refs.file as any).files)) this.upload(x); - }, - - upload(file: File, name?: string) { - (this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder, name); - }, - - onDragover(e) { - const isFile = e.dataTransfer.items[0].kind == 'file'; - const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; - if (isFile || isDriveFile) { - e.preventDefault(); - e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; - } - }, - - onDrop(e): void { - // ファイルだったら - if (e.dataTransfer.files.length > 0) { - e.preventDefault(); - for (const x of Array.from(e.dataTransfer.files)) this.upload(x); - return; - } - - //#region ドライブのファイル - const driveFile = e.dataTransfer.getData('mk_drive_file'); - if (driveFile != null && driveFile != '') { - const file = JSON.parse(driveFile); - this.files.push(file); - e.preventDefault(); - } - //#endregion - }, - - async emoji() { - const Picker = await import('../../../desktop/views/components/emoji-picker-dialog.vue').then(m => m.default); - const button = this.$refs.emoji; - const rect = button.getBoundingClientRect(); - const vm = this.$root.new(Picker, { - x: button.offsetWidth + rect.left + window.pageXOffset, - y: rect.top + window.pageYOffset - }); - vm.$once('chosen', emoji => { - insertTextAtCursor(this.$refs.text, emoji); - }); - }, - - post() { - this.posting = true; - - let visibility = 'public'; - let localOnly = false; - - const m = this.$store.state.settings.defaultNoteVisibility.match(/^local-(.+)/); - if (m) { - visibility = m[1]; - localOnly = true; - } else { - visibility = this.$store.state.settings.defaultNoteVisibility; - } - - this.$root.api('notes/create', { - text: this.text == '' ? undefined : this.text, - fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, - visibility, - localOnly, - }).then(data => { - this.clear(); - }).catch(err => { - this.$root.dialog({ - type: 'error', - text: this.$t('something-happened') - }); - }).then(() => { - this.posting = false; - this.$nextTick(() => { - this.$refs.text.focus(); - }); - }); - }, - - clear() { - this.text = ''; - this.files = []; - } - } -}); -</script> - -<style lang="stylus" scoped> -.lhcuptdmcdkfwmipgazeawoiuxpzaclc-body - > .textarea - > .emoji - position absolute - top 0 - right 0 - padding 10px - font-size 18px - color var(--text) - opacity 0.5 - - &:hover - color var(--textHighlighted) - opacity 1 - - &:active - color var(--primary) - opacity 1 - - > textarea - display block - width 100% - max-width 100% - min-width 100% - padding 16px - color var(--desktopPostFormTextareaFg) - outline none - background var(--desktopPostFormTextareaBg) - border none - border-bottom solid 1px var(--faceDivider) - padding-right 30px - - &:focus - & + .emoji - opacity 0.7 - - > input[type=file] - display none - - > footer - display flex - padding 8px - - > button:not(.post) - color var(--text) - - &:hover - color var(--textHighlighted) - - > .post - display block - margin 0 0 0 auto - padding 0 10px - height 28px - color var(--primaryForeground) - background var(--primary) !important - outline none - border none - border-radius 4px - transition background 0.1s ease - cursor pointer - - &:hover - background var(--primaryLighten10) !important - - &:active - background var(--primaryDarken10) !important - transition background 0s ease - -</style> diff --git a/src/client/app/common/views/widgets/posts-monitor.vue b/src/client/app/common/views/widgets/posts-monitor.vue deleted file mode 100644 index 64c3b51540..0000000000 --- a/src/client/app/common/views/widgets/posts-monitor.vue +++ /dev/null @@ -1,203 +0,0 @@ -<template> -<div class="mkw-posts-monitor"> - <ui-container :show-header="props.design == 0" :naked="props.design == 2"> - <template #header><fa icon="chart-line"/>{{ $t('title') }}</template> - <template #func><button @click="toggle" :title="$t('toggle')"><fa icon="sort"/></button></template> - - <div class="qpdmibaztplkylerhdbllwcokyrfxeyj" :class="{ dual: props.view == 0 }"> - <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" v-show="props.view != 2"> - <defs> - <linearGradient :id="localGradientId" x1="0" x2="0" y1="1" y2="0"> - <stop offset="0%" stop-color="hsl(200, 80%, 70%)"></stop> - <stop offset="100%" stop-color="hsl(90, 80%, 70%)"></stop> - </linearGradient> - <mask :id="localMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> - <polygon - :points="localPolygonPoints" - fill="#fff" - fill-opacity="0.5"/> - <polyline - :points="localPolylinePoints" - fill="none" - stroke="#fff" - stroke-width="1"/> - <circle - :cx="localHeadX" - :cy="localHeadY" - r="1.5" - fill="#fff"/> - </mask> - </defs> - <rect - x="-2" y="-2" - :width="viewBoxX + 4" :height="viewBoxY + 4" - :style="`stroke: none; fill: url(#${ localGradientId }); mask: url(#${ localMaskId })`"/> - <text x="1" y="5">Local</text> - </svg> - <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" v-show="props.view != 1"> - <defs> - <linearGradient :id="fediGradientId" x1="0" x2="0" y1="1" y2="0"> - <stop offset="0%" stop-color="hsl(200, 80%, 70%)"></stop> - <stop offset="100%" stop-color="hsl(90, 80%, 70%)"></stop> - </linearGradient> - <mask :id="fediMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> - <polygon - :points="fediPolygonPoints" - fill="#fff" - fill-opacity="0.5"/> - <polyline - :points="fediPolylinePoints" - fill="none" - stroke="#fff" - stroke-width="1"/> - <circle - :cx="fediHeadX" - :cy="fediHeadY" - r="1.5" - fill="#fff"/> - </mask> - </defs> - <rect - x="-2" y="-2" - :width="viewBoxX + 4" :height="viewBoxY + 4" - :style="`stroke: none; fill: url(#${ fediGradientId }); mask: url(#${ fediMaskId })`"/> - <text x="1" y="5">Fedi</text> - </svg> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; -import { v4 as uuid } from 'uuid'; - -export default define({ - name: 'posts-monitor', - props: () => ({ - design: 0, - view: 0 - }) -}).extend({ - i18n: i18n('common/views/widgets/posts-monitor.vue'), - - data() { - return { - connection: null, - viewBoxY: 30, - stats: [], - fediGradientId: uuid(), - fediMaskId: uuid(), - localGradientId: uuid(), - localMaskId: uuid(), - fediPolylinePoints: '', - localPolylinePoints: '', - fediPolygonPoints: '', - localPolygonPoints: '', - fediHeadX: null, - fediHeadY: null, - localHeadX: null, - localHeadY: null - }; - }, - computed: { - viewBoxX(): number { - return this.props.view == 0 ? 50 : 100; - } - }, - watch: { - viewBoxX() { - this.draw(); - } - }, - mounted() { - this.connection = this.$root.stream.useSharedConnection('notesStats'); - - this.connection.on('stats', this.onStats); - this.connection.on('statsLog', this.onStatsLog); - this.connection.send('requestLog',{ - id: Math.random().toString().substr(2, 8) - }); - }, - beforeDestroy() { - this.connection.dispose(); - }, - methods: { - toggle() { - if (this.props.view == 2) { - this.props.view = 0; - } else { - this.props.view++; - } - this.save(); - }, - func() { - if (this.props.design == 2) { - this.props.design = 0; - } else { - this.props.design++; - } - this.save(); - }, - draw() { - const stats = this.props.view == 0 ? this.stats.slice(-50) : this.stats; - const fediPeak = Math.max.apply(null, stats.map(x => x.all)) || 1; - const localPeak = Math.max.apply(null, stats.map(x => x.local)) || 1; - - const fediPolylinePoints = stats.map((s, i) => [this.viewBoxX - ((stats.length - 1) - i), (1 - (s.all / fediPeak)) * this.viewBoxY]); - const localPolylinePoints = stats.map((s, i) => [this.viewBoxX - ((stats.length - 1) - i), (1 - (s.local / localPeak)) * this.viewBoxY]); - this.fediPolylinePoints = fediPolylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); - this.localPolylinePoints = localPolylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); - - this.fediPolygonPoints = `${this.viewBoxX - (stats.length - 1)},${ this.viewBoxY } ${ this.fediPolylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; - this.localPolygonPoints = `${this.viewBoxX - (stats.length - 1)},${ this.viewBoxY } ${ this.localPolylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; - - this.fediHeadX = fediPolylinePoints[fediPolylinePoints.length - 1][0]; - this.fediHeadY = fediPolylinePoints[fediPolylinePoints.length - 1][1]; - this.localHeadX = localPolylinePoints[localPolylinePoints.length - 1][0]; - this.localHeadY = localPolylinePoints[localPolylinePoints.length - 1][1]; - }, - onStats(stats) { - this.stats.push(stats); - if (this.stats.length > 100) this.stats.shift(); - this.draw(); - }, - onStatsLog(statsLog) { - for (const stats of statsLog) this.onStats(stats); - } - } -}); -</script> - -<style lang="stylus" scoped> -.qpdmibaztplkylerhdbllwcokyrfxeyj - &.dual - > svg - width 50% - float left - - &:first-child - padding-right 5px - - &:last-child - padding-left 5px - - > svg - display block - padding 10px - width 100% - - > text - font-size 5px - fill var(--chartCaption) - - > tspan - opacity 0.5 - - &:after - content "" - display block - clear both - -</style> diff --git a/src/client/app/common/views/widgets/queue.vue b/src/client/app/common/views/widgets/queue.vue deleted file mode 100644 index 6e49f1efb0..0000000000 --- a/src/client/app/common/views/widgets/queue.vue +++ /dev/null @@ -1,173 +0,0 @@ -<template> -<div> - <ui-container :show-header="!props.compact"> - <template #header><fa :icon="faTasks"/>Queue</template> - - <div class="mntrproz"> - <div> - <b>In</b> - <span v-if="latestStats">{{ latestStats.inbox.activeSincePrevTick | number }} / {{ latestStats.inbox.delayed | number }}</span> - <div ref="in"></div> - </div> - <div> - <b>Out</b> - <span v-if="latestStats">{{ latestStats.deliver.activeSincePrevTick | number }} / {{ latestStats.deliver.delayed | number }}</span> - <div ref="out"></div> - </div> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../define-widget'; -import { faTasks } from '@fortawesome/free-solid-svg-icons'; -import ApexCharts from 'apexcharts'; - -export default define({ - name: 'queue', - props: () => ({ - compact: false - }) -}).extend({ - data() { - return { - stats: [], - inChart: null, - outChart: null, - faTasks - }; - }, - - watch: { - stats(stats) { - this.inChart.updateSeries([{ - type: 'area', - data: stats.map((x, i) => ({ x: i, y: x.inbox.activeSincePrevTick })) - }, { - type: 'area', - data: stats.map((x, i) => ({ x: i, y: x.inbox.active })) - }, { - type: 'line', - data: stats.map((x, i) => ({ x: i, y: x.inbox.waiting })) - }, { - type: 'line', - data: stats.map((x, i) => ({ x: i, y: x.inbox.delayed })) - }]); - this.outChart.updateSeries([{ - type: 'area', - data: stats.map((x, i) => ({ x: i, y: x.deliver.activeSincePrevTick })) - }, { - type: 'area', - data: stats.map((x, i) => ({ x: i, y: x.deliver.active })) - }, { - type: 'line', - data: stats.map((x, i) => ({ x: i, y: x.deliver.waiting })) - }, { - type: 'line', - data: stats.map((x, i) => ({ x: i, y: x.deliver.delayed })) - }]); - } - }, - - computed: { - latestStats(): any { - return this.stats[this.stats.length - 1]; - } - }, - - mounted() { - const chartOpts = { - chart: { - type: 'area', - height: 70, - animations: { - dynamicAnimation: { - enabled: false - } - }, - sparkline: { - enabled: true, - } - }, - dataLabels: { - enabled: false - }, - tooltip: { - enabled: false - }, - stroke: { - curve: 'straight', - width: 1 - }, - colors: ['#00E396', '#00BCD4', '#FFB300', '#e53935'], - series: [{ data: [] }, { data: [] }, { data: [] }, { data: [] }] as any, - yaxis: { - min: 0, - } - }; - - this.inChart = new ApexCharts(this.$refs.in, chartOpts); - this.outChart = new ApexCharts(this.$refs.out, chartOpts); - - this.inChart.render(); - this.outChart.render(); - - const connection = this.$root.stream.useSharedConnection('queueStats'); - connection.on('stats', this.onStats); - connection.on('statsLog', this.onStatsLog); - connection.send('requestLog', { - id: Math.random().toString().substr(2, 8), - length: 50 - }); - - this.$once('hook:beforeDestroy', () => { - connection.dispose(); - this.inChart.destroy(); - this.outChart.destroy(); - }); - }, - - methods: { - func() { - this.props.compact = !this.props.compact; - this.save(); - }, - - onStats(stats) { - this.stats.push(stats); - if (this.stats.length > 50) this.stats.shift(); - }, - - onStatsLog(statsLog) { - for (const stats of statsLog.reverse()) { - this.onStats(stats); - } - } - } -}); -</script> - -<style lang="stylus" scoped> -.mntrproz - display flex - padding 4px - - > div - width 50% - padding 4px - - > b - display block - font-size 12px - color var(--text) - - > span - position absolute - top 4px - right 4px - opacity 0.7 - font-size 12px - color var(--text) - -</style> diff --git a/src/client/app/common/views/widgets/rss.vue b/src/client/app/common/views/widgets/rss.vue deleted file mode 100644 index c1a66bfebb..0000000000 --- a/src/client/app/common/views/widgets/rss.vue +++ /dev/null @@ -1,116 +0,0 @@ -<template> -<div class="mkw-rss"> - <ui-container :show-header="!props.compact"> - <template #header><fa icon="rss-square"/>RSS</template> - <template #func><button title="設定" @click="setting"><fa icon="cog"/></button></template> - - <div class="mkw-rss--body" :data-mobile="platform == 'mobile'"> - <p class="fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p> - <div class="feed" v-else> - <a v-for="item in items" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a> - </div> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; - -export default define({ - name: 'rss', - props: () => ({ - compact: false, - url: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews' - }) -}).extend({ - i18n: i18n(), - data() { - return { - items: [], - fetching: true, - clock: null - }; - }, - mounted() { - this.fetch(); - this.clock = setInterval(this.fetch, 60000); - }, - beforeDestroy() { - clearInterval(this.clock); - }, - methods: { - func() { - this.props.compact = !this.props.compact; - this.save(); - }, - fetch() { - fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.props.url}`, { - }).then(res => { - res.json().then(feed => { - this.items = feed.items; - this.fetching = false; - }); - }); - }, - setting() { - this.$root.dialog({ - title: 'URL', - input: { - type: 'url', - default: this.props.url - } - }).then(({ canceled, result: url }) => { - if (canceled) return; - this.props.url = url; - this.save(); - this.fetch(); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mkw-rss - .mkw-rss--body - .feed - padding 12px 16px - font-size 0.9em - - > a - display block - padding 4px 0 - color var(--text) - border-bottom dashed var(--lineWidth) var(--faceDivider) - white-space nowrap - text-overflow ellipsis - overflow hidden - - &:last-child - border-bottom none - - .fetching - margin 0 - padding 16px - text-align center - color var(--text) - - > [data-icon] - margin-right 4px - - &[data-mobile] - background var(--face) - - .feed - padding 0 - - > a - padding 8px 16px - border-bottom none - - &:nth-child(even) - background rgba(#000, 0.05) - -</style> diff --git a/src/client/app/common/views/widgets/server.cpu-memory.vue b/src/client/app/common/views/widgets/server.cpu-memory.vue deleted file mode 100644 index 799773a70c..0000000000 --- a/src/client/app/common/views/widgets/server.cpu-memory.vue +++ /dev/null @@ -1,156 +0,0 @@ -<template> -<div class="cpu-memory"> - <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`"> - <defs> - <linearGradient :id="cpuGradientId" x1="0" x2="0" y1="1" y2="0"> - <stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop> - <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop> - </linearGradient> - <mask :id="cpuMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> - <polygon - :points="cpuPolygonPoints" - fill="#fff" - fill-opacity="0.5"/> - <polyline - :points="cpuPolylinePoints" - fill="none" - stroke="#fff" - stroke-width="1"/> - <circle - :cx="cpuHeadX" - :cy="cpuHeadY" - r="1.5" - fill="#fff"/> - </mask> - </defs> - <rect - x="-2" y="-2" - :width="viewBoxX + 4" :height="viewBoxY + 4" - :style="`stroke: none; fill: url(#${ cpuGradientId }); mask: url(#${ cpuMaskId })`"/> - <text x="1" y="5">CPU <tspan>{{ cpuP }}%</tspan></text> - </svg> - <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`"> - <defs> - <linearGradient :id="memGradientId" x1="0" x2="0" y1="1" y2="0"> - <stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop> - <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop> - </linearGradient> - <mask :id="memMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> - <polygon - :points="memPolygonPoints" - fill="#fff" - fill-opacity="0.5"/> - <polyline - :points="memPolylinePoints" - fill="none" - stroke="#fff" - stroke-width="1"/> - <circle - :cx="memHeadX" - :cy="memHeadY" - r="1.5" - fill="#fff"/> - </mask> - </defs> - <rect - x="-2" y="-2" - :width="viewBoxX + 4" :height="viewBoxY + 4" - :style="`stroke: none; fill: url(#${ memGradientId }); mask: url(#${ memMaskId })`"/> - <text x="1" y="5">MEM <tspan>{{ memP }}%</tspan></text> - </svg> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { v4 as uuid } from 'uuid'; - -export default Vue.extend({ - props: ['connection'], - data() { - return { - viewBoxX: 50, - viewBoxY: 30, - stats: [], - cpuGradientId: uuid(), - cpuMaskId: uuid(), - memGradientId: uuid(), - memMaskId: uuid(), - cpuPolylinePoints: '', - memPolylinePoints: '', - cpuPolygonPoints: '', - memPolygonPoints: '', - cpuHeadX: null, - cpuHeadY: null, - memHeadX: null, - memHeadY: null, - cpuP: '', - memP: '' - }; - }, - mounted() { - this.connection.on('stats', this.onStats); - this.connection.on('statsLog', this.onStatsLog); - this.connection.send('requestLog', { - id: Math.random().toString().substr(2, 8) - }); - }, - beforeDestroy() { - this.connection.off('stats', this.onStats); - this.connection.off('statsLog', this.onStatsLog); - }, - methods: { - onStats(stats) { - this.stats.push(stats); - if (this.stats.length > 50) this.stats.shift(); - - const cpuPolylinePoints = this.stats.map((s, i) => [this.viewBoxX - ((this.stats.length - 1) - i), (1 - s.cpu_usage) * this.viewBoxY]); - const memPolylinePoints = this.stats.map((s, i) => [this.viewBoxX - ((this.stats.length - 1) - i), (1 - (s.mem.used / s.mem.total)) * this.viewBoxY]); - this.cpuPolylinePoints = cpuPolylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); - this.memPolylinePoints = memPolylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); - - this.cpuPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${this.viewBoxY} ${this.cpuPolylinePoints} ${this.viewBoxX},${this.viewBoxY}`; - this.memPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${this.viewBoxY} ${this.memPolylinePoints} ${this.viewBoxX},${this.viewBoxY}`; - - this.cpuHeadX = cpuPolylinePoints[cpuPolylinePoints.length - 1][0]; - this.cpuHeadY = cpuPolylinePoints[cpuPolylinePoints.length - 1][1]; - this.memHeadX = memPolylinePoints[memPolylinePoints.length - 1][0]; - this.memHeadY = memPolylinePoints[memPolylinePoints.length - 1][1]; - - this.cpuP = (stats.cpu_usage * 100).toFixed(0); - this.memP = (stats.mem.used / stats.mem.total * 100).toFixed(0); - }, - onStatsLog(statsLog) { - for (const stats of statsLog.reverse()) this.onStats(stats); - } - } -}); -</script> - -<style lang="stylus" scoped> -.cpu-memory - > svg - display block - padding 10px - width 50% - float left - - &:first-child - padding-right 5px - - &:last-child - padding-left 5px - - > text - font-size 5px - fill var(--chartCaption) - - > tspan - opacity 0.5 - - &:after - content "" - display block - clear both - -</style> diff --git a/src/client/app/common/views/widgets/server.cpu.vue b/src/client/app/common/views/widgets/server.cpu.vue deleted file mode 100644 index c08971e11c..0000000000 --- a/src/client/app/common/views/widgets/server.cpu.vue +++ /dev/null @@ -1,68 +0,0 @@ -<template> -<div class="cpu"> - <x-pie class="pie" :value="usage"/> - <div> - <p><fa icon="microchip"/>CPU</p> - <p>{{ meta.cpu.cores }} Logical cores</p> - <p>{{ meta.cpu.model }}</p> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import XPie from './server.pie.vue'; - -export default Vue.extend({ - components: { - XPie - }, - props: ['connection', 'meta'], - data() { - return { - usage: 0 - }; - }, - mounted() { - this.connection.on('stats', this.onStats); - }, - beforeDestroy() { - this.connection.off('stats', this.onStats); - }, - methods: { - onStats(stats) { - this.usage = stats.cpu_usage; - } - } -}); -</script> - -<style lang="stylus" scoped> -.cpu - > .pie - padding 10px - height 100px - float left - - > div - float left - width calc(100% - 100px) - padding 10px 10px 10px 0 - - > p - margin 0 - font-size 12px - color var(--chartCaption) - - &:first-child - font-weight bold - - > [data-icon] - margin-right 4px - - &:after - content "" - display block - clear both - -</style> diff --git a/src/client/app/common/views/widgets/server.disk.vue b/src/client/app/common/views/widgets/server.disk.vue deleted file mode 100644 index 039c4f5c29..0000000000 --- a/src/client/app/common/views/widgets/server.disk.vue +++ /dev/null @@ -1,76 +0,0 @@ -<template> -<div class="disk"> - <x-pie class="pie" :value="usage"/> - <div> - <p><fa :icon="['far', 'hdd']"/>Storage</p> - <p>Total: {{ total | bytes(1) }}</p> - <p>Free: {{ available | bytes(1) }}</p> - <p>Used: {{ used | bytes(1) }}</p> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import XPie from './server.pie.vue'; - -export default Vue.extend({ - components: { - XPie - }, - props: ['connection'], - data() { - return { - usage: 0, - total: 0, - used: 0, - available: 0 - }; - }, - mounted() { - this.connection.on('stats', this.onStats); - }, - beforeDestroy() { - this.connection.off('stats', this.onStats); - }, - methods: { - onStats(stats) { - stats.disk.used = stats.disk.total - stats.disk.free; - this.usage = stats.disk.used / stats.disk.total; - this.total = stats.disk.total; - this.used = stats.disk.used; - this.available = stats.disk.available; - } - } -}); -</script> - -<style lang="stylus" scoped> -.disk - > .pie - padding 10px - height 100px - float left - - > div - float left - width calc(100% - 100px) - padding 10px 10px 10px 0 - - > p - margin 0 - font-size 12px - color var(--chartCaption) - - &:first-child - font-weight bold - - > [data-icon] - margin-right 4px - - &:after - content "" - display block - clear both - -</style> diff --git a/src/client/app/common/views/widgets/server.info.vue b/src/client/app/common/views/widgets/server.info.vue deleted file mode 100644 index c6e0d68b11..0000000000 --- a/src/client/app/common/views/widgets/server.info.vue +++ /dev/null @@ -1,28 +0,0 @@ -<template> -<div class="info"> - <p>Maintainer: <b><a :href="'mailto:' + meta.maintainerEmail" target="_blank">{{ meta.maintainerName }}</a></b></p> - <p>Machine: {{ meta.machine }}</p> - <p>Node: {{ meta.node }}</p> - <p>PSQL: {{ meta.psql }}</p> - <p>Redis: {{ meta.redis }}</p> - <p>Version: {{ meta.version }} </p> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: ['meta'] -}); -</script> - -<style lang="stylus" scoped> -.info - padding 10px 14px - - > p - margin 0 - font-size 12px - color var(--text) -</style> diff --git a/src/client/app/common/views/widgets/server.memory.vue b/src/client/app/common/views/widgets/server.memory.vue deleted file mode 100644 index c3b2f3a101..0000000000 --- a/src/client/app/common/views/widgets/server.memory.vue +++ /dev/null @@ -1,76 +0,0 @@ -<template> -<div class="memory"> - <x-pie class="pie" :value="usage"/> - <div> - <p><fa icon="memory"/>Memory</p> - <p>Total: {{ total | bytes(1) }}</p> - <p>Used: {{ used | bytes(1) }}</p> - <p>Free: {{ free | bytes(1) }}</p> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import XPie from './server.pie.vue'; - -export default Vue.extend({ - components: { - XPie - }, - props: ['connection'], - data() { - return { - usage: 0, - total: 0, - used: 0, - free: 0 - }; - }, - mounted() { - this.connection.on('stats', this.onStats); - }, - beforeDestroy() { - this.connection.off('stats', this.onStats); - }, - methods: { - onStats(stats) { - stats.mem.free = stats.mem.total - stats.mem.used; - this.usage = stats.mem.used / stats.mem.total; - this.total = stats.mem.total; - this.used = stats.mem.used; - this.free = stats.mem.free; - } - } -}); -</script> - -<style lang="stylus" scoped> -.memory - > .pie - padding 10px - height 100px - float left - - > div - float left - width calc(100% - 100px) - padding 10px 10px 10px 0 - - > p - margin 0 - font-size 12px - color var(--chartCaption) - - &:first-child - font-weight bold - - > [data-icon] - margin-right 4px - - &:after - content "" - display block - clear both - -</style> diff --git a/src/client/app/common/views/widgets/server.pie.vue b/src/client/app/common/views/widgets/server.pie.vue deleted file mode 100644 index ce342fd41b..0000000000 --- a/src/client/app/common/views/widgets/server.pie.vue +++ /dev/null @@ -1,61 +0,0 @@ -<template> -<svg viewBox="0 0 1 1" preserveAspectRatio="none"> - <circle - :r="r" - cx="50%" cy="50%" - fill="none" - stroke-width="0.1" - stroke="rgba(0, 0, 0, 0.05)"/> - <circle - :r="r" - cx="50%" cy="50%" - :stroke-dasharray="Math.PI * (r * 2)" - :stroke-dashoffset="strokeDashoffset" - fill="none" - stroke-width="0.1" - :stroke="color"/> - <text x="50%" y="50%" dy="0.05" text-anchor="middle">{{ (value * 100).toFixed(0) }}%</text> -</svg> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: { - value: { - type: Number, - required: true - } - }, - data() { - return { - r: 0.4 - }; - }, - computed: { - color(): string { - return `hsl(${180 - (this.value * 180)}, 80%, 70%)`; - }, - strokeDashoffset(): number { - return (1 - this.value) * (Math.PI * (this.r * 2)); - } - } -}); -</script> - -<style lang="stylus" scoped> -svg - display block - height 100% - - > circle - transform-origin center - transform rotate(-90deg) - transition stroke-dashoffset 0.5s ease - - > text - font-size 0.15px - fill var(--chartCaption) - -</style> diff --git a/src/client/app/common/views/widgets/server.uptimes.vue b/src/client/app/common/views/widgets/server.uptimes.vue deleted file mode 100644 index 0da5c4ec50..0000000000 --- a/src/client/app/common/views/widgets/server.uptimes.vue +++ /dev/null @@ -1,47 +0,0 @@ -<template> -<div class="uptimes"> - <p>Uptimes</p> - <p>Process: {{ process }}</p> - <p>OS: {{ os }}</p> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import formatUptime from '../../scripts/format-uptime'; - -export default Vue.extend({ - props: ['connection'], - data() { - return { - process: 0, - os: 0 - }; - }, - mounted() { - this.connection.on('stats', this.onStats); - }, - beforeDestroy() { - this.connection.off('stats', this.onStats); - }, - methods: { - onStats(stats) { - this.process = formatUptime(stats.process_uptime); - this.os = formatUptime(stats.os_uptime); - } - } -}); -</script> - -<style lang="stylus" scoped> -.uptimes - padding 10px 14px - - > p - margin 0 - font-size 12px - color var(--text) - - &:first-child - font-weight bold -</style> diff --git a/src/client/app/common/views/widgets/server.vue b/src/client/app/common/views/widgets/server.vue deleted file mode 100644 index 90a0f0171b..0000000000 --- a/src/client/app/common/views/widgets/server.vue +++ /dev/null @@ -1,96 +0,0 @@ -<template> -<div class="mkw-server"> - <ui-container :show-header="props.design == 0" :naked="props.design == 2"> - <template #header><fa icon="server"/>{{ $t('title') }}</template> - <template #func><button @click="toggle" :title="$t('toggle')"><fa icon="sort"/></button></template> - - <p :class="$style.fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p> - <template v-if="!fetching"> - <x-cpu-memory v-show="props.view == 0" :connection="connection"/> - <x-cpu v-show="props.view == 1" :connection="connection" :meta="meta"/> - <x-memory v-show="props.view == 2" :connection="connection"/> - <x-disk v-show="props.view == 3" :connection="connection"/> - <x-uptimes v-show="props.view == 4" :connection="connection"/> - <x-info v-show="props.view == 5" :connection="connection" :meta="meta"/> - </template> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; -import XCpuMemory from './server.cpu-memory.vue'; -import XCpu from './server.cpu.vue'; -import XMemory from './server.memory.vue'; -import XDisk from './server.disk.vue'; -import XUptimes from './server.uptimes.vue'; -import XInfo from './server.info.vue'; - -export default define({ - name: 'server', - props: () => ({ - design: 0, - view: 0 - }) -}).extend({ - i18n: i18n('common/views/widgets/server.vue'), - - components: { - XCpuMemory, - XCpu, - XMemory, - XDisk, - XUptimes, - XInfo - }, - data() { - return { - fetching: true, - meta: null, - connection: null - }; - }, - mounted() { - this.$root.getMeta().then(meta => { - this.meta = meta; - this.fetching = false; - }); - - this.connection = this.$root.stream.useSharedConnection('serverStats'); - }, - beforeDestroy() { - this.connection.dispose(); - }, - methods: { - toggle() { - if (this.props.view == 5) { - this.props.view = 0; - } else { - this.props.view++; - } - this.save(); - }, - func() { - if (this.props.design == 2) { - this.props.design = 0; - } else { - this.props.design++; - } - this.save(); - } - } -}); -</script> - -<style lang="stylus" module> -.fetching - margin 0 - padding 16px - text-align center - color var(--text) - - > [data-icon] - margin-right 4px - -</style> diff --git a/src/client/app/common/views/widgets/slideshow.vue b/src/client/app/common/views/widgets/slideshow.vue deleted file mode 100644 index 23ccb9da6b..0000000000 --- a/src/client/app/common/views/widgets/slideshow.vue +++ /dev/null @@ -1,165 +0,0 @@ -<template> -<div class="mkw-slideshow" :data-mobile="platform == 'mobile'"> - <div @click="choose"> - <p v-if="props.folder === undefined"> - <template v-if="isCustomizeMode">{{ $t('folder-customize-mode') }}</template> - <template v-else>{{ $t('folder') }}</template> - </p> - <p v-if="props.folder !== undefined && images.length == 0 && !fetching">{{ $t('no-image') }}</p> - <div ref="slideA" class="slide a"></div> - <div ref="slideB" class="slide b"></div> - </div> -</div> -</template> - -<script lang="ts"> -import anime from 'animejs'; -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; - -export default define({ - name: 'slideshow', - props: () => ({ - folder: undefined, - size: 0 - }) -}).extend({ - i18n: i18n('common/views/widgets/slideshow.vue'), - - data() { - return { - images: [], - fetching: true, - clock: null - }; - }, - mounted() { - this.$nextTick(() => { - this.applySize(); - }); - - if (this.props.folder !== undefined) { - this.fetch(); - } - - this.clock = setInterval(this.change, 10000); - }, - beforeDestroy() { - clearInterval(this.clock); - }, - methods: { - func() { - this.resize(); - }, - applySize() { - let h; - - if (this.props.size == 1) { - h = 250; - } else { - h = 170; - } - - this.$el.style.height = `${h}px`; - }, - resize() { - if (this.props.size == 1) { - this.props.size = 0; - } else { - this.props.size++; - } - this.save(); - - this.applySize(); - }, - change() { - if (this.images.length == 0) return; - - const index = Math.floor(Math.random() * this.images.length); - const img = `url(${ this.images[index].url })`; - - (this.$refs.slideB as any).style.backgroundImage = img; - - anime({ - targets: this.$refs.slideB, - opacity: 1, - duration: 1000, - easing: 'linear', - complete: () => { - // 既にこのウィジェットがunmountされていたら要素がない - if ((this.$refs.slideA as any) == null) return; - - (this.$refs.slideA as any).style.backgroundImage = img; - anime({ - targets: this.$refs.slideB, - opacity: 0, - duration: 0 - }); - } - }); - }, - fetch() { - this.fetching = true; - - this.$root.api('drive/files', { - folderId: this.props.folder, - type: 'image/*', - limit: 100 - }).then(images => { - this.images = images; - this.fetching = false; - (this.$refs.slideA as any).style.backgroundImage = ''; - (this.$refs.slideB as any).style.backgroundImage = ''; - this.change(); - }); - }, - choose() { - this.$chooseDriveFolder().then(folder => { - this.props.folder = folder ? folder.id : null; - this.save(); - this.fetch(); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mkw-slideshow - overflow hidden - background #fff - border solid 1px rgba(#000, 0.075) - border-radius 6px - - &[data-mobile] - border none - border-radius 8px - box-shadow 0 0 0 1px rgba(#000, 0.2) - - > div - width 100% - height 100% - cursor pointer - - > p - display block - margin 1em - text-align center - color #888 - - > * - pointer-events none - - > .slide - position absolute - top 0 - left 0 - width 100% - height 100% - background-size cover - background-position center - - &.b - opacity 0 - -</style> diff --git a/src/client/app/common/views/widgets/tips.vue b/src/client/app/common/views/widgets/tips.vue deleted file mode 100644 index 9e047ef47c..0000000000 --- a/src/client/app/common/views/widgets/tips.vue +++ /dev/null @@ -1,109 +0,0 @@ -<template> -<div class="mkw-tips"> - <p ref="tip"><fa :icon="['far', 'lightbulb']"/><span v-html="tip"></span></p> -</div> -</template> - -<script lang="ts"> -import anime from 'animejs'; -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; - -export default define({ - name: 'tips' -}).extend({ - i18n: i18n('common/views/widgets/tips.vue'), - - data() { - return { - tips: [], - tip: null, - clock: null - }; - }, - created() { - this.tips = [ - this.$t('tips-line1'), - this.$t('tips-line2'), - this.$t('tips-line3'), - this.$t('tips-line4'), - this.$t('tips-line5'), - this.$t('tips-line6'), - this.$t('tips-line7'), - this.$t('tips-line8'), - this.$t('tips-line9'), - this.$t('tips-line10'), - this.$t('tips-line11'), - this.$t('tips-line13'), - this.$t('tips-line14'), - this.$t('tips-line17'), - this.$t('tips-line19'), - this.$t('tips-line20'), - this.$t('tips-line21'), - this.$t('tips-line23'), - this.$t('tips-line24'), - this.$t('tips-line25') - ]; - }, - mounted() { - this.$nextTick(() => { - this.set(); - }); - - this.clock = setInterval(this.change, 20000); - }, - beforeDestroy() { - clearInterval(this.clock); - }, - methods: { - set() { - this.tip = this.tips[Math.floor(Math.random() * this.tips.length)]; - }, - change() { - anime({ - targets: this.$refs.tip, - opacity: 0, - duration: 500, - easing: 'linear', - complete: this.set - }); - - setTimeout(() => { - anime({ - targets: this.$refs.tip, - opacity: 1, - duration: 500, - easing: 'linear' - }); - }, 500); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mkw-tips - overflow visible !important - opacity 0.8 - - > p - display block - margin 0 - padding 0 12px - text-align center - font-size 0.7em - color var(--text) - - > [data-icon] - margin-right 4px - - kbd - display inline - padding 0 6px - margin 0 2px - font-size 1em - font-family inherit - border solid 1px var(--text) - border-radius 2px - -</style> diff --git a/src/client/app/common/views/widgets/version.vue b/src/client/app/common/views/widgets/version.vue deleted file mode 100644 index e8f6c08f34..0000000000 --- a/src/client/app/common/views/widgets/version.vue +++ /dev/null @@ -1,30 +0,0 @@ -<template> -<p>ver {{ version }} ({{ codename }})</p> -</template> - -<script lang="ts"> -import { version, codename } from '../../../config'; -import define from '../../../common/define-widget'; -export default define({ - name: 'version' -}).extend({ - data() { - return { - version, - codename - }; - } -}); -</script> - -<style lang="stylus" scoped> -p - display block - margin 0 - padding 0 12px - text-align center - font-size 0.7em - color var(--text) - opacity 0.8 - -</style> diff --git a/src/client/app/desktop/api/update-avatar.ts b/src/client/app/desktop/api/update-avatar.ts deleted file mode 100644 index 6b88b51ef1..0000000000 --- a/src/client/app/desktop/api/update-avatar.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { apiUrl, locale } from '../../config'; -import ProgressDialog from '../views/components/progress-dialog.vue'; - -export default ($root: any) => { - - const cropImage = file => new Promise(async (resolve, reject) => { - const CropWindow = await import('../views/components/crop-window.vue').then(x => x.default); - const w = $root.new(CropWindow, { - image: file, - title: locale['desktop']['avatar-crop-title'], - aspectRatio: 1 / 1 - }); - - w.$once('cropped', blob => { - const data = new FormData(); - data.append('i', $root.$store.state.i.token); - data.append('file', blob, file.name + '.cropped.png'); - - $root.api('drive/folders/find', { - name: locale['desktop']['avatar'] - }).then(avatarFolder => { - if (avatarFolder.length === 0) { - $root.api('drive/folders/create', { - name: locale['desktop']['avatar'] - }).then(iconFolder => { - resolve(upload(data, iconFolder)); - }); - } else { - resolve(upload(data, avatarFolder[0])); - } - }); - }); - - w.$once('skipped', () => { - resolve(file); - }); - - w.$once('cancelled', reject); - - document.body.appendChild(w.$el); - }); - - const upload = (data, folder) => new Promise((resolve, reject) => { - const dialog = $root.new(ProgressDialog, { - title: locale['desktop']['uploading-avatar'] - }); - document.body.appendChild(dialog.$el); - - if (folder) data.append('folderId', folder.id); - - const xhr = new XMLHttpRequest(); - xhr.open('POST', apiUrl + '/drive/files/create', true); - xhr.onload = e => { - const file = JSON.parse((e.target as any).response); - (dialog as any).close(); - resolve(file); - }; - xhr.onerror = reject; - - xhr.upload.onprogress = e => { - if (e.lengthComputable) (dialog as any).update(e.loaded, e.total); - }; - - xhr.send(data); - }); - - const setAvatar = file => { - return $root.api('i/update', { - avatarId: file.id - }).then(i => { - $root.$store.commit('updateIKeyValue', { - key: 'avatarId', - value: i.avatarId - }); - $root.$store.commit('updateIKeyValue', { - key: 'avatarUrl', - value: i.avatarUrl - }); - - $root.dialog({ - title: locale['desktop']['avatar-updated'], - text: null - }); - - return i; - }).catch(err => { - switch (err.id) { - case 'f419f9f8-2f4d-46b1-9fb4-49d3a2fd7191': - $root.dialog({ - type: 'error', - title: locale['desktop']['unable-to-process'], - text: locale['desktop']['invalid-filetype'] - }); - break; - default: - $root.dialog({ - type: 'error', - text: locale['desktop']['unable-to-process'] - }); - } - }); - }; - - return (file = null) => { - const selectedFile = file - ? Promise.resolve(file) - : $root.$chooseDriveFile({ - multiple: false, - type: 'image/*', - title: locale['desktop']['choose-avatar'] - }); - - return selectedFile - .then(cropImage) - .then(setAvatar) - .catch(err => err && console.warn(err)); - }; -}; diff --git a/src/client/app/desktop/api/update-banner.ts b/src/client/app/desktop/api/update-banner.ts deleted file mode 100644 index 09632b1941..0000000000 --- a/src/client/app/desktop/api/update-banner.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { apiUrl, locale } from '../../config'; -import ProgressDialog from '../views/components/progress-dialog.vue'; - -export default ($root: any) => { - - const cropImage = file => new Promise(async (resolve, reject) => { - const CropWindow = await import('../views/components/crop-window.vue').then(x => x.default); - const w = $root.new(CropWindow, { - image: file, - title: locale['desktop']['banner-crop-title'], - aspectRatio: 16 / 9 - }); - - w.$once('cropped', blob => { - const data = new FormData(); - data.append('i', $root.$store.state.i.token); - data.append('file', blob, file.name + '.cropped.png'); - - $root.api('drive/folders/find', { - name: locale['desktop']['banner'] - }).then(bannerFolder => { - if (bannerFolder.length === 0) { - $root.api('drive/folders/create', { - name: locale['desktop']['banner'] - }).then(iconFolder => { - resolve(upload(data, iconFolder)); - }); - } else { - resolve(upload(data, bannerFolder[0])); - } - }); - }); - - w.$once('skipped', () => { - resolve(file); - }); - - w.$once('cancelled', reject); - - document.body.appendChild(w.$el); - }); - - const upload = (data, folder) => new Promise((resolve, reject) => { - const dialog = $root.new(ProgressDialog, { - title: locale['desktop']['uploading-banner'] - }); - document.body.appendChild(dialog.$el); - - if (folder) data.append('folderId', folder.id); - - const xhr = new XMLHttpRequest(); - xhr.open('POST', apiUrl + '/drive/files/create', true); - xhr.onload = e => { - const file = JSON.parse((e.target as any).response); - (dialog as any).close(); - resolve(file); - }; - xhr.onerror = reject; - - xhr.upload.onprogress = e => { - if (e.lengthComputable) (dialog as any).update(e.loaded, e.total); - }; - - xhr.send(data); - }); - - const setBanner = file => { - return $root.api('i/update', { - bannerId: file.id - }).then(i => { - $root.$store.commit('updateIKeyValue', { - key: 'bannerId', - value: i.bannerId - }); - $root.$store.commit('updateIKeyValue', { - key: 'bannerUrl', - value: i.bannerUrl - }); - - $root.dialog({ - title: locale['desktop']['banner-updated'], - text: null - }); - - return i; - }).catch(err => { - switch (err.id) { - case '75aedb19-2afd-4e6d-87fc-67941256fa60': - $root.dialog({ - type: 'error', - title: locale['desktop']['unable-to-process'], - text: locale['desktop']['invalid-filetype'] - }); - break; - default: - $root.dialog({ - type: 'error', - text: locale['desktop']['unable-to-process'] - }); - } - }); - }; - - return (file = null) => { - const selectedFile = file - ? Promise.resolve(file) - : $root.$chooseDriveFile({ - multiple: false, - type: 'image/*', - title: locale['desktop']['choose-banner'] - }); - - return selectedFile - .then(cropImage) - .then(setBanner) - .catch(err => err && console.warn(err)); - }; -}; diff --git a/src/client/app/desktop/assets/grid.svg b/src/client/app/desktop/assets/grid.svg deleted file mode 100644 index d1d72cd8ce..0000000000 --- a/src/client/app/desktop/assets/grid.svg +++ /dev/null @@ -1,150 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<!-- Created with Inkscape (http://www.inkscape.org/) --> - -<svg - xmlns:dc="http://purl.org/dc/elements/1.1/" - xmlns:cc="http://creativecommons.org/ns#" - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" - xmlns:svg="http://www.w3.org/2000/svg" - xmlns="http://www.w3.org/2000/svg" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="32" - height="32" - viewBox="0 0 8.4666665 8.4666669" - version="1.1" - id="svg8" - inkscape:version="0.92.1 r15371" - sodipodi:docname="grid.svg"> - <defs - id="defs2" /> - <sodipodi:namedview - id="base" - pagecolor="#ffffff" - bordercolor="#666666" - borderopacity="1.0" - inkscape:pageopacity="0.0" - inkscape:pageshadow="2" - inkscape:zoom="22.4" - inkscape:cx="14.687499" - inkscape:cy="14.558219" - inkscape:document-units="px" - inkscape:current-layer="layer1" - showgrid="true" - units="px" - showguides="true" - inkscape:window-width="1920" - inkscape:window-height="1017" - inkscape:window-x="-8" - inkscape:window-y="1072" - inkscape:window-maximized="1"> - <inkscape:grid - type="xygrid" - id="grid3680" - empspacing="8" - empcolor="#ff3fff" - empopacity="0.41176471" /> - </sodipodi:namedview> - <metadata - id="metadata5"> - <rdf:RDF> - <cc:Work - rdf:about=""> - <dc:format>image/svg+xml</dc:format> - <dc:type - rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> - <dc:title></dc:title> - </cc:Work> - </rdf:RDF> - </metadata> - <g - inkscape:label="レイヤー 1" - inkscape:groupmode="layer" - id="layer1" - transform="translate(0,-288.53331)"> - <path - style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 0,296.99998 v -8.46667 h 8.4666666 l 10e-8,0.26458 H 0.26458333 l 0,8.20209 z" - id="path3684" - inkscape:connector-curvature="0" - sodipodi:nodetypes="ccccccc" /> - <path - style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 4.2333334,292.23748 h 0.2645833 v 0.52916 h 0.5291667 l 0,0.26459 H 4.4979167 v 0.52917 H 4.2333334 v -0.52917 H 3.7041667 l 0,-0.26459 h 0.5291667 z" - id="path4491" - inkscape:connector-curvature="0" - sodipodi:nodetypes="ccccccccccccc" /> - <path - style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 3.4395833,292.76664 0,0.26459 H 2.38125 l 0,-0.26459 z" - id="path4493" - inkscape:connector-curvature="0" - sodipodi:nodetypes="ccccc" /> - <path - style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 6.3499999,292.76664 10e-8,0.26459 H 5.2916667 l -1e-7,-0.26459 z" - id="path4493-2" - inkscape:connector-curvature="0" - sodipodi:nodetypes="ccccc" /> - <path - style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 7.6729167,292.76664 v 0.26459 H 6.6145834 v -0.26459 z" - id="path4493-6" - inkscape:connector-curvature="0" - sodipodi:nodetypes="ccccc" /> - <path - style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 2.1166666,292.76664 1e-7,0.26459 H 1.0583334 l -1e-7,-0.26459 z" - id="path4493-1" - inkscape:connector-curvature="0" - sodipodi:nodetypes="ccccc" /> - <path - style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 4.2333333,291.97289 0.2645834,0 v -1.05833 l -0.2645834,0 z" - id="path4522" - inkscape:connector-curvature="0" - sodipodi:nodetypes="ccccc" /> - <path - style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 4.2333334,290.64997 0.2645833,1e-5 v -1.05833 l -0.2645833,-1e-5 z" - id="path4522-7" - inkscape:connector-curvature="0" - sodipodi:nodetypes="ccccc" /> - <path - style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 4.2333334,294.88331 h 0.2645833 v -1.05833 H 4.2333334 Z" - id="path4522-5" - inkscape:connector-curvature="0" - sodipodi:nodetypes="ccccc" /> - <path - style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 4.2333333,296.20622 h 0.2645833 v -1.05833 H 4.2333333 Z" - id="path4522-74" - inkscape:connector-curvature="0" - sodipodi:nodetypes="ccccc" /> - <path - style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 4.2333334,289.32706 0.2645834,10e-6 -10e-8,-0.52918 -0.2645834,-10e-6 z" - id="path4522-7-4" - inkscape:connector-curvature="0" - sodipodi:nodetypes="ccccc" /> - <path - style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 4.2333332,296.99998 h 0.2645835 l 0,-0.52917 H 4.2333333 Z" - id="path4522-7-4-4" - inkscape:connector-curvature="0" - sodipodi:nodetypes="ccccc" /> - <path - style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 0.79375,292.76664 -3e-8,0.26459 -0.52916667,0 3e-8,-0.26459 z" - id="path4493-1-7" - inkscape:connector-curvature="0" - sodipodi:nodetypes="ccccc" /> - <path - style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 8.4666667,292.76664 v 0.26459 l -0.5291667,0 v -0.26459 z" - id="path4493-1-7-2" - inkscape:connector-curvature="0" - sodipodi:nodetypes="ccccc" /> - </g> -</svg> diff --git a/src/client/app/desktop/assets/header-icon.svg b/src/client/app/desktop/assets/header-icon.svg deleted file mode 100644 index d677d2d163..0000000000 --- a/src/client/app/desktop/assets/header-icon.svg +++ /dev/null @@ -1,150 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<!-- Created with Inkscape (http://www.inkscape.org/) --> - -<svg - xmlns:dc="http://purl.org/dc/elements/1.1/" - xmlns:cc="http://creativecommons.org/ns#" - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" - xmlns:svg="http://www.w3.org/2000/svg" - xmlns="http://www.w3.org/2000/svg" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="512" - height="512" - viewBox="0 0 135.46667 135.46667" - version="1.1" - id="svg8" - inkscape:version="0.92.1 r15371" - sodipodi:docname="header-icon.dark.svg" - inkscape:export-filename="C:\Users\syuilo\projects\misskey\assets\favicon\32.png" - inkscape:export-xdpi="6" - inkscape:export-ydpi="6"> - <defs - id="defs2"> - <inkscape:path-effect - effect="simplify" - id="path-effect5115" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - <inkscape:path-effect - effect="simplify" - id="path-effect5111" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - <inkscape:path-effect - effect="simplify" - id="path-effect5104" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - </defs> - <sodipodi:namedview - id="base" - pagecolor="#ffffff" - bordercolor="#666666" - borderopacity="1.0" - inkscape:pageopacity="0.0" - inkscape:pageshadow="2" - inkscape:zoom="1.4142136" - inkscape:cx="114.309" - inkscape:cy="251.50613" - inkscape:document-units="px" - inkscape:current-layer="g4502" - showgrid="true" - units="px" - inkscape:snap-bbox="true" - inkscape:bbox-nodes="true" - inkscape:snap-bbox-edge-midpoints="false" - inkscape:snap-smooth-nodes="true" - inkscape:snap-center="true" - inkscape:snap-page="true" - inkscape:window-width="1920" - inkscape:window-height="1027" - inkscape:window-x="-8" - inkscape:window-y="1072" - inkscape:window-maximized="1" - inkscape:snap-object-midpoints="true" - inkscape:snap-midpoints="true" - inkscape:object-paths="true" - fit-margin-top="0" - fit-margin-left="0" - fit-margin-right="0" - fit-margin-bottom="0" - objecttolerance="1" - guidetolerance="1" - inkscape:snap-nodes="false" - inkscape:snap-others="false"> - <inkscape:grid - type="xygrid" - id="grid4504" - spacingx="4.2333334" - spacingy="4.2333334" - empcolor="#ff3fff" - empopacity="0.25098039" - empspacing="4" /> - </sodipodi:namedview> - <metadata - id="metadata5"> - <rdf:RDF> - <cc:Work - rdf:about=""> - <dc:format>image/svg+xml</dc:format> - <dc:type - rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> - <dc:title></dc:title> - </cc:Work> - </rdf:RDF> - </metadata> - <g - inkscape:label="レイヤー 1" - inkscape:groupmode="layer" - id="layer1" - transform="translate(-30.809093,-111.78601)"> - <g - id="g4502" - transform="matrix(1.096096,0,0,1.096096,-2.960633,-44.023579)"> - <g - style="fill-opacity:1" - transform="translate(-1.3333333e-6,-1.3439941e-6)" - id="g5125"> - <g - transform="matrix(0.91391326,0,0,0.91391326,7.9719907,17.595761)" - id="text4489" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.28950602px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - aria-label="Mi"> - <path - sodipodi:nodetypes="zccssscssccscczzzccsccsscscsccz" - inkscape:connector-curvature="0" - id="path5210" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill-opacity:1;stroke-width:0.28950602px" - d="m 75.196381,231.17126 c -5.855419,0.0202 -10.885068,-3.50766 -13.2572,-7.61584 -1.266603,-1.79454 -3.772419,-2.43291 -3.807919,0 v 11.2332 c 0,4.51309 -1.645397,8.41504 -4.936191,11.70583 -3.196772,3.19677 -7.098714,4.79516 -11.705826,4.79516 -4.513089,0 -8.415031,-1.59839 -11.705825,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -61.7729 c 0,-3.47884 0.987238,-6.6286 2.961715,-9.44928 2.068499,-2.91471 4.701135,-4.9362 7.897906,-6.06447 1.786431,-0.65816 3.666885,-0.98724 5.641362,-0.98724 5.077225,0 9.308247,1.97448 12.693064,5.92343 1.786431,1.97448 2.820681,3.00873 3.102749,3.10275 0,0 13.408119,16.21319 13.78421,16.49526 0.376091,0.28206 1.480789,2.43848 4.127113,2.43848 2.646324,0 3.89218,-2.15642 4.26827,-2.43848 0.376091,-0.28207 13.784088,-16.49526 13.784088,-16.49526 0.09402,0.094 1.081261,-0.94022 2.961715,-3.10275 3.478837,-3.94895 7.756866,-5.92343 12.834096,-5.92343 1.88045,0 3.76091,0.32908 5.64136,0.98724 3.19677,1.12827 5.7824,3.14976 7.75688,6.06447 2.06849,2.82068 3.10274,5.97044 3.10274,9.44928 v 61.7729 c 0,4.51309 -1.6454,8.41504 -4.93619,11.70583 -3.19677,3.19677 -7.09871,4.79516 -11.70582,4.79516 -4.51309,0 -8.41504,-1.59839 -11.705828,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -11.2332 c -0.277898,-3.06563 -2.987588,-1.13379 -3.948953,0 -2.538613,4.70114 -7.401781,7.59567 -13.2572,7.61584 z" /> - <path - inkscape:connector-curvature="0" - id="path5212" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill-opacity:1;stroke-width:0.28950602px" - d="m 145.83461,185.00361 q -5.92343,0 -10.15445,-4.08999 -4.08999,-4.23102 -4.08999,-10.15445 0,-5.92343 4.08999,-10.01342 4.23102,-4.23102 10.15445,-4.23102 5.92343,0 10.15445,4.23102 4.23102,4.08999 4.23102,10.01342 0,5.92343 -4.23102,10.15445 -4.23102,4.08999 -10.15445,4.08999 z m 0.14103,2.82068 q 5.92343,0 10.01342,4.23102 4.23102,4.23102 4.23102,10.15445 v 34.83541 q 0,5.92343 -4.23102,10.15445 -4.08999,4.08999 -10.01342,4.08999 -5.92343,0 -10.15445,-4.08999 -4.23102,-4.23102 -4.23102,-10.15445 v -34.83541 q 0,-5.92343 4.23102,-10.15445 4.23102,-4.23102 10.15445,-4.23102 z" /> - </g> - </g> - </g> - </g> -</svg> diff --git a/src/client/app/desktop/assets/index.jpg b/src/client/app/desktop/assets/index.jpg Binary files differdeleted file mode 100644 index c054188159..0000000000 --- a/src/client/app/desktop/assets/index.jpg +++ /dev/null diff --git a/src/client/app/desktop/assets/remove.png b/src/client/app/desktop/assets/remove.png Binary files differdeleted file mode 100644 index c2e222a0fc..0000000000 --- a/src/client/app/desktop/assets/remove.png +++ /dev/null diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts deleted file mode 100644 index 914e162c9a..0000000000 --- a/src/client/app/desktop/script.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * Desktop Client - */ - -import Vue from 'vue'; -import VueRouter from 'vue-router'; - -// Style -import './style.styl'; - -import init from '../init'; -import composeNotification from '../common/scripts/compose-notification'; - -import MkHome from './views/home/home.vue'; -import MkSelectDrive from './views/pages/selectdrive.vue'; -import MkDrive from './views/pages/drive.vue'; -import MkMessagingRoom from './views/pages/messaging-room.vue'; -import MkReversi from './views/pages/games/reversi.vue'; -import MkShare from '../common/views/pages/share.vue'; -import MkFollow from '../common/views/pages/follow.vue'; -import MkNotFound from '../common/views/pages/not-found.vue'; -import MkSettings from './views/pages/settings.vue'; -import DeckColumn from '../common/views/deck/deck.column-template.vue'; - -import Ctx from './views/components/context-menu.vue'; -import RenoteFormWindow from './views/components/renote-form-window.vue'; -import MkChooseFileFromDriveWindow from './views/components/choose-file-from-drive-window.vue'; -import MkChooseFolderFromDriveWindow from './views/components/choose-folder-from-drive-window.vue'; -import MkHomeTimeline from './views/home/timeline.vue'; -import Notification from './views/components/ui-notification.vue'; - -import { url } from '../config'; -import MiOS from '../mios'; - -/** - * init - */ -init(async (launch, os) => { - Vue.mixin({ - methods: { - $contextmenu(e, menu, opts?) { - const o = opts || {}; - const vm = this.$root.new(Ctx, { - menu, - x: e.pageX - window.pageXOffset, - y: e.pageY - window.pageYOffset, - }); - vm.$once('closed', () => { - if (o.closed) o.closed(); - }); - }, - - $post(opts) { - const o = opts || {}; - if (o.renote) { - const vm = this.$root.new(RenoteFormWindow, { - note: o.renote, - animation: o.animation == null ? true : o.animation - }); - if (o.cb) vm.$once('closed', o.cb); - } else { - this.$root.newAsync(() => import('./views/components/post-form-window.vue').then(m => m.default), { - reply: o.reply, - mention: o.mention, - animation: o.animation == null ? true : o.animation, - initialText: o.initialText, - instant: o.instant, - initialNote: o.initialNote, - }).then(vm => { - if (o.cb) vm.$once('closed', o.cb); - }); - } - }, - - $chooseDriveFile(opts) { - return new Promise((res, rej) => { - const o = opts || {}; - - if (document.body.clientWidth > 800) { - const w = this.$root.new(MkChooseFileFromDriveWindow, { - title: o.title, - type: o.type, - multiple: o.multiple, - initFolder: o.currentFolder - }); - w.$once('selected', file => { - res(file); - }); - } else { - window['cb'] = file => { - res(file); - }; - - window.open(url + `/selectdrive?multiple=${o.multiple}`, - 'choose_drive_window', - 'height=500, width=800'); - } - }); - }, - - $chooseDriveFolder(opts) { - return new Promise((res, rej) => { - const o = opts || {}; - const w = this.$root.new(MkChooseFolderFromDriveWindow, { - title: o.title, - initFolder: o.currentFolder - }); - w.$once('selected', folder => { - res(folder); - }); - }); - }, - - $notify(message) { - this.$root.new(Notification, { - message - }); - } - } - }); - - // Register directives - require('./views/directives'); - - // Register components - require('./views/components'); - require('./views/widgets'); - - // Init router - const router = new VueRouter({ - mode: 'history', - routes: [ - os.store.state.device.inDeckMode - ? { path: '/', name: 'index', component: () => import('../common/views/deck/deck.vue').then(m => m.default), children: [ - { path: '/@:user', component: () => import('../common/views/deck/deck.user-column.vue').then(m => m.default), children: [ - { path: '', name: 'user', component: () => import('../common/views/deck/deck.user-column.home.vue').then(m => m.default) }, - { path: 'following', component: () => import('../common/views/pages/following.vue').then(m => m.default) }, - { path: 'followers', component: () => import('../common/views/pages/followers.vue').then(m => m.default) }, - ]}, - { path: '/notes/:note', name: 'note', component: () => import('../common/views/deck/deck.note-column.vue').then(m => m.default) }, - { path: '/search', component: () => import('../common/views/deck/deck.search-column.vue').then(m => m.default) }, - { path: '/tags/:tag', name: 'tag', component: () => import('../common/views/deck/deck.hashtag-column.vue').then(m => m.default) }, - { path: '/featured', name: 'featured', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/featured.vue').then(m => m.default), platform: 'deck' }) }, - { path: '/explore', name: 'explore', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default) }) }, - { path: '/explore/tags/:tag', name: 'explore-tag', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default), tag: route.params.tag }) }, - { path: '/i/favorites', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/favorites.vue').then(m => m.default), platform: 'deck' }) }, - { path: '/i/pages', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/pages.vue').then(m => m.default) }) }, - { path: '/i/lists', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-lists.vue').then(m => m.default) }) }, - { path: '/i/lists/:listId', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-list-editor.vue').then(m => m.default), listId: route.params.listId }) }, - { path: '/i/groups', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-groups.vue').then(m => m.default) }) }, - { path: '/i/groups/:groupId', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-group-editor.vue').then(m => m.default), groupId: route.params.groupId }) }, - { path: '/i/follow-requests', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/follow-requests.vue').then(m => m.default) }) }, - { path: '/@:username/pages/:pageName', name: 'page', props: true, component: () => import('../common/views/deck/deck.page-column.vue').then(m => m.default) }, - ]} - : { path: '/', component: MkHome, children: [ - { path: '', name: 'index', component: MkHomeTimeline }, - { path: '/@:user', component: () => import('./views/home/user/index.vue').then(m => m.default), children: [ - { path: '', name: 'user', component: () => import('./views/home/user/user.home.vue').then(m => m.default) }, - { path: 'following', component: () => import('../common/views/pages/following.vue').then(m => m.default) }, - { path: 'followers', component: () => import('../common/views/pages/followers.vue').then(m => m.default) }, - ]}, - { path: '/notes/:note', name: 'note', component: () => import('./views/home/note.vue').then(m => m.default) }, - { path: '/search', component: () => import('./views/home/search.vue').then(m => m.default) }, - { path: '/tags/:tag', name: 'tag', component: () => import('./views/home/tag.vue').then(m => m.default) }, - { path: '/featured', name: 'featured', component: () => import('../common/views/pages/featured.vue').then(m => m.default), props: { platform: 'desktop' } }, - { path: '/explore', name: 'explore', component: () => import('../common/views/pages/explore.vue').then(m => m.default) }, - { path: '/explore/tags/:tag', name: 'explore-tag', props: true, component: () => import('../common/views/pages/explore.vue').then(m => m.default) }, - { path: '/i/favorites', component: () => import('../common/views/pages/favorites.vue').then(m => m.default), props: { platform: 'desktop' } }, - { path: '/i/pages', component: () => import('../common/views/pages/pages.vue').then(m => m.default) }, - { path: '/i/lists', component: () => import('../common/views/pages/user-lists.vue').then(m => m.default) }, - { path: '/i/lists/:listId', props: true, component: () => import('../common/views/pages/user-list-editor.vue').then(m => m.default) }, - { path: '/i/groups', component: () => import('../common/views/pages/user-groups.vue').then(m => m.default) }, - { path: '/i/groups/:groupId', props: true, component: () => import('../common/views/pages/user-group-editor.vue').then(m => m.default) }, - { path: '/i/follow-requests', component: () => import('../common/views/pages/follow-requests.vue').then(m => m.default) }, - { path: '/i/pages/new', component: () => import('../common/views/pages/page-editor/page-editor.vue').then(m => m.default) }, - { path: '/i/pages/edit/:pageId', component: () => import('../common/views/pages/page-editor/page-editor.vue').then(m => m.default), props: route => ({ initPageId: route.params.pageId }) }, - { path: '/@:user/pages/:page', component: () => import('../common/views/pages/page.vue').then(m => m.default), props: route => ({ pageName: route.params.page, username: route.params.user }) }, - { path: '/@:user/pages/:pageName/view-source', component: () => import('../common/views/pages/page-editor/page-editor.vue').then(m => m.default), props: route => ({ initUser: route.params.user, initPageName: route.params.pageName }) }, - ]}, - { path: '/i/pages/new', component: () => import('../common/views/pages/page-editor/page-editor.vue').then(m => m.default) }, - { path: '/i/pages/edit/:pageId', component: () => import('../common/views/pages/page-editor/page-editor.vue').then(m => m.default), props: route => ({ initPageId: route.params.pageId }) }, - { path: '/@:user/pages/:pageName/view-source', component: () => import('../common/views/pages/page-editor/page-editor.vue').then(m => m.default), props: route => ({ initUser: route.params.user, initPageName: route.params.pageName }) }, - { path: '/i/messaging/group/:group', component: MkMessagingRoom }, - { path: '/i/messaging/:user', component: MkMessagingRoom }, - { path: '/i/drive', component: MkDrive }, - { path: '/i/drive/folder/:folder', component: MkDrive }, - { path: '/i/settings', redirect: '/i/settings/profile' }, - { path: '/i/settings/:page', component: MkSettings }, - { path: '/selectdrive', component: MkSelectDrive }, - { path: '/@:acct/room', props: true, component: () => import('../common/views/pages/room/room.vue').then(m => m.default) }, - { path: '/share', component: MkShare }, - { path: '/games/reversi/:game?', component: MkReversi }, - { path: '/authorize-follow', component: MkFollow }, - { path: '/deck', redirect: '/' }, - { path: '*', component: MkNotFound } - ], - scrollBehavior(to, from, savedPosition) { - return { x: 0, y: 0 }; - } - }); - - // Launch the app - const [app, _] = launch(router); - - /** - * Init Notification - */ - if ('Notification' in window && os.store.getters.isSignedIn) { - // 許可を得ていなかったらリクエスト - if ((Notification as any).permission == 'default') { - await Notification.requestPermission(); - } - - if ((Notification as any).permission == 'granted') { - registerNotifications(os); - } - } -}, true); - -function registerNotifications(os: MiOS) { - const stream = os.stream; - - if (stream == null) return; - - const connection = stream.useSharedConnection('main'); - - connection.on('notification', notification => { - const _n = composeNotification('notification', notification); - const n = new Notification(_n.title, { - body: _n.body, - icon: _n.icon - }); - setTimeout(n.close.bind(n), 6000); - }); - - connection.on('driveFileCreated', file => { - const _n = composeNotification('driveFileCreated', file); - const n = new Notification(_n.title, { - body: _n.body, - icon: _n.icon - }); - setTimeout(n.close.bind(n), 5000); - }); - - connection.on('unreadMessagingMessage', message => { - const _n = composeNotification('unreadMessagingMessage', message); - const n = new Notification(_n.title, { - body: _n.body, - icon: _n.icon - }); - n.onclick = () => { - n.close(); - /*(riot as any).mount(document.body.appendChild(document.createElement('mk-messaging-room-window')), { - user: message.user - });*/ - }; - setTimeout(n.close.bind(n), 7000); - }); - - connection.on('reversiInvited', matching => { - const _n = composeNotification('reversiInvited', matching); - const n = new Notification(_n.title, { - body: _n.body, - icon: _n.icon - }); - }); -} diff --git a/src/client/app/desktop/style.styl b/src/client/app/desktop/style.styl deleted file mode 100644 index 249d3db2ed..0000000000 --- a/src/client/app/desktop/style.styl +++ /dev/null @@ -1,40 +0,0 @@ -@import "../app" -@import "../reset" - -*::input-placeholder - color #D8CBC5 - -*:focus - outline none - -html - height 100% - background var(--bg) - - &, div, textarea - scrollbar-width thin - - &, * - scrollbar-color var(--scrollbarHandle) var(--scrollbarTrack) - - &:hover - scrollbar-color var(--scrollbarHandleHover) var(--scrollbarTrack) - - &:active - scrollbar-color var(--primary) var(--scrollbarTrack) - - &::-webkit-scrollbar - width 6px - height 6px - - &::-webkit-scrollbar-track - background var(--scrollbarTrack) - - &::-webkit-scrollbar-thumb - background var(--scrollbarHandle) - - &:hover - background var(--scrollbarHandleHover) - - &:active - background var(--primary) diff --git a/src/client/app/desktop/views/components/activity.calendar.vue b/src/client/app/desktop/views/components/activity.calendar.vue deleted file mode 100644 index da74a97f68..0000000000 --- a/src/client/app/desktop/views/components/activity.calendar.vue +++ /dev/null @@ -1,80 +0,0 @@ -<template> -<svg viewBox="0 0 21 7"> - <rect v-for="record in data" class="day" - width="1" height="1" - :x="record.x" :y="record.date.weekday" - rx="1" ry="1" - fill="transparent"> - <title>{{ record.date.year }}/{{ record.date.month + 1 }}/{{ record.date.day }}</title> - </rect> - <rect v-for="record in data" class="day" - :width="record.v" :height="record.v" - :x="record.x + ((1 - record.v) / 2)" :y="record.date.weekday + ((1 - record.v) / 2)" - rx="1" ry="1" - :fill="record.color" - style="pointer-events: none;"/> - <rect class="today" - width="1" height="1" - :x="data[0].x" :y="data[0].date.weekday" - rx="1" ry="1" - fill="none" - stroke-width="0.1" - stroke="#f73520"/> -</svg> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: ['data'], - created() { - for (const d of this.data) { - d.total = d.notes + d.replies + d.renotes; - } - const peak = Math.max.apply(null, this.data.map(d => d.total)); - - const now = new Date(); - const year = now.getFullYear(); - const month = now.getMonth(); - const day = now.getDate(); - - let x = 20; - this.data.slice().forEach((d, i) => { - d.x = x; - - const date = new Date(year, month, day - i); - d.date = { - year: date.getFullYear(), - month: date.getMonth(), - day: date.getDate(), - weekday: date.getDay() - }; - - d.v = peak == 0 ? 0 : d.total / (peak / 2); - if (d.v > 1) d.v = 1; - const ch = d.date.weekday == 0 || d.date.weekday == 6 ? 275 : 170; - const cs = d.v * 100; - const cl = 15 + ((1 - d.v) * 80); - d.color = `hsl(${ch}, ${cs}%, ${cl}%)`; - - if (d.date.weekday == 0) x--; - }); - } -}); -</script> - -<style lang="stylus" scoped> -svg - display block - padding 10px - width 100% - - > rect - transform-origin center - - &.day - &:hover - fill rgba(#000, 0.05) - -</style> diff --git a/src/client/app/desktop/views/components/activity.chart.vue b/src/client/app/desktop/views/components/activity.chart.vue deleted file mode 100644 index 648b64a3fe..0000000000 --- a/src/client/app/desktop/views/components/activity.chart.vue +++ /dev/null @@ -1,108 +0,0 @@ -<template> -<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" @mousedown.prevent="onMousedown"> - <title>{{ $t('total') }}<br/>{{ $t('notes') }}<br/>{{ $t('replies') }}<br/>{{ $t('renotes') }}</title> - <polyline - :points="pointsNote" - fill="none" - stroke-width="1" - stroke="#41ddde"/> - <polyline - :points="pointsReply" - fill="none" - stroke-width="1" - stroke="#f7796c"/> - <polyline - :points="pointsRenote" - fill="none" - stroke-width="1" - stroke="#a1de41"/> - <polyline - :points="pointsTotal" - fill="none" - stroke-width="1" - stroke="#555" - stroke-dasharray="2 2"/> -</svg> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -function dragListen(fn) { - window.addEventListener('mousemove', fn); - window.addEventListener('mouseleave', dragClear.bind(null, fn)); - window.addEventListener('mouseup', dragClear.bind(null, fn)); -} - -function dragClear(fn) { - window.removeEventListener('mousemove', fn); - window.removeEventListener('mouseleave', dragClear); - window.removeEventListener('mouseup', dragClear); -} - -export default Vue.extend({ - i18n: i18n('desktop/views/components/activity.chart.vue'), - props: ['data'], - data() { - return { - viewBoxX: 147, - viewBoxY: 60, - zoom: 1, - pos: 0, - pointsNote: null, - pointsReply: null, - pointsRenote: null, - pointsTotal: null - }; - }, - created() { - for (const d of this.data) { - d.total = d.notes + d.replies + d.renotes; - } - - this.render(); - }, - methods: { - render() { - const peak = Math.max.apply(null, this.data.map(d => d.total)); - if (peak != 0) { - const data = this.data.slice().reverse(); - this.pointsNote = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.notes / peak)) * this.viewBoxY}`).join(' '); - this.pointsReply = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.replies / peak)) * this.viewBoxY}`).join(' '); - this.pointsRenote = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.renotes / peak)) * this.viewBoxY}`).join(' '); - this.pointsTotal = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.total / peak)) * this.viewBoxY}`).join(' '); - } - }, - onMousedown(e) { - const clickX = e.clientX; - const clickY = e.clientY; - const baseZoom = this.zoom; - const basePos = this.pos; - - // 動かした時 - dragListen(me => { - let moveLeft = me.clientX - clickX; - let moveTop = me.clientY - clickY; - - this.zoom = baseZoom + (-moveTop / 20); - this.pos = basePos + moveLeft; - if (this.zoom < 1) this.zoom = 1; - if (this.pos > 0) this.pos = 0; - if (this.pos < -(((this.data.length - 1) * this.zoom) - this.viewBoxX)) this.pos = -(((this.data.length - 1) * this.zoom) - this.viewBoxX); - - this.render(); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -svg - display block - padding 10px - width 100% - cursor all-scroll - -</style> diff --git a/src/client/app/desktop/views/components/activity.vue b/src/client/app/desktop/views/components/activity.vue deleted file mode 100644 index 2cac125041..0000000000 --- a/src/client/app/desktop/views/components/activity.vue +++ /dev/null @@ -1,86 +0,0 @@ -<template> -<div class="mk-activity"> - <ui-container :show-header="design == 0" :naked="design == 2"> - <template #header><fa icon="chart-bar"/>{{ $t('title') }}</template> - <template #func><button :title="$t('toggle')" @click="toggle"><fa icon="sort"/></button></template> - - <p :class="$style.fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p> - <template v-else> - <x-calendar v-show="view == 0" :data="[].concat(activity)"/> - <x-chart v-show="view == 1" :data="[].concat(activity)"/> - </template> - </ui-container> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XCalendar from './activity.calendar.vue'; -import XChart from './activity.chart.vue'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/activity.vue'), - components: { - XCalendar, - XChart - }, - props: { - design: { - default: 0 - }, - initView: { - default: 0 - }, - user: { - type: Object, - required: true - } - }, - data() { - return { - fetching: true, - activity: null, - view: this.initView - }; - }, - mounted() { - this.$root.api('charts/user/notes', { - userId: this.user.id, - span: 'day', - limit: 7 * 21 - }).then(activity => { - this.activity = activity.diffs.normal.map((_, i) => ({ - total: activity.diffs.normal[i] + activity.diffs.reply[i] + activity.diffs.renote[i], - notes: activity.diffs.normal[i], - replies: activity.diffs.reply[i], - renotes: activity.diffs.renote[i] - })); - this.fetching = false; - }); - }, - methods: { - toggle() { - if (this.view == 1) { - this.view = 0; - this.$emit('viewChanged', this.view); - } else { - this.view++; - this.$emit('viewChanged', this.view); - } - } - } -}); -</script> - -<style lang="stylus" module> -.fetching - margin 0 - padding 16px - text-align center - color var(--text) - - > [data-icon] - margin-right 4px - -</style> diff --git a/src/client/app/desktop/views/components/calendar.vue b/src/client/app/desktop/views/components/calendar.vue deleted file mode 100644 index cdeac51638..0000000000 --- a/src/client/app/desktop/views/components/calendar.vue +++ /dev/null @@ -1,252 +0,0 @@ -<template> -<div class="mk-calendar" :data-melt="design == 4 || design == 5" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> - <template v-if="design == 0 || design == 1"> - <button @click="prev" :title="$t('prev')"><fa icon="chevron-circle-left"/></button> - <p class="title">{{ $t('title', { year, month }) }}</p> - <button @click="next" :title="$t('next')"><fa icon="chevron-circle-right"/></button> - </template> - - <div class="calendar"> - <template v-if="design == 0 || design == 2 || design == 4"> - <div class="weekday" - v-for="(day, i) in Array(7).fill(0)" - :data-today="year == today.getFullYear() && month == today.getMonth() + 1 && today.getDay() == i" - :data-is-weekend="i == 0 || i == 6" - >{{ weekdayText[i] }}</div> - </template> - <div v-for="n in paddingDays"></div> - <div class="day" v-for="(day, i) in days" - :data-today="isToday(i + 1)" - :data-selected="isSelected(i + 1)" - :data-is-out-of-range="isOutOfRange(i + 1)" - :data-is-weekend="isWeekend(i + 1)" - @click="go(i + 1)" - :title="isOutOfRange(i + 1) ? null : $t('go')" - > - <div>{{ i + 1 }}</div> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -const eachMonthDays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; - -function isLeapYear(year) { - return !(year & (year % 25 ? 3 : 15)); -} - -export default Vue.extend({ - i18n: i18n('desktop/views/components/calendar.vue'), - props: { - design: { - default: 0 - }, - start: { - type: Date, - required: false - } - }, - data() { - return { - today: new Date(), - year: new Date().getFullYear(), - month: new Date().getMonth() + 1, - selected: new Date(), - weekdayText: [ - this.$t('@.weekday-short.sunday'), - this.$t('@.weekday-short.monday'), - this.$t('@.weekday-short.tuesday'), - this.$t('@.weekday-short.wednesday'), - this.$t('@.weekday-short.thursday'), - this.$t('@.weekday-short.friday'), - this.$t('@.weekday-short.saturday') - ] - }; - }, - computed: { - paddingDays(): number { - const date = new Date(this.year, this.month - 1, 1); - return date.getDay(); - }, - days(): number { - let days = eachMonthDays[this.month - 1]; - - // うるう年なら+1日 - if (this.month == 2 && isLeapYear(this.year)) days++; - - return days; - } - }, - methods: { - isToday(day) { - return this.year == this.today.getFullYear() && this.month == this.today.getMonth() + 1 && day == this.today.getDate(); - }, - - isSelected(day) { - return this.year == this.selected.getFullYear() && this.month == this.selected.getMonth() + 1 && day == this.selected.getDate(); - }, - - isOutOfRange(day) { - const test = (new Date(this.year, this.month - 1, day)).getTime(); - return test > this.today.getTime() || - (this.start ? test < (this.start as any).getTime() : false); - }, - - isWeekend(day) { - const weekday = (new Date(this.year, this.month - 1, day)).getDay(); - return weekday == 0 || weekday == 6; - }, - - prev() { - if (this.month == 1) { - this.year = this.year - 1; - this.month = 12; - } else { - this.month--; - } - }, - - next() { - if (this.month == 12) { - this.year = this.year + 1; - this.month = 1; - } else { - this.month++; - } - }, - - go(day) { - if (this.isOutOfRange(day)) return; - const date = new Date(this.year, this.month - 1, day, 23, 59, 59, 999); - this.selected = date; - this.$emit('chosen', this.selected); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-calendar - color var(--calendarDay) - background var(--face) - overflow hidden - - &.round - border-radius 6px - - &.shadow - box-shadow 0 3px 8px rgba(0, 0, 0, 0.2) - - &[data-melt] - background transparent !important - border none !important - - > .title - z-index 1 - margin 0 - padding 0 16px - text-align center - line-height 42px - font-size 0.9em - font-weight bold - color var(--faceHeaderText) - background var(--faceHeader) - box-shadow 0 var(--lineWidth) rgba(#000, 0.07) - - > [data-icon] - margin-right 4px - - > button - position absolute - z-index 2 - top 0 - padding 0 - width 42px - font-size 0.9em - line-height 42px - color var(--faceTextButton) - - &:hover - color var(--faceTextButtonHover) - - &:active - color var(--faceTextButtonActive) - - &:first-of-type - left 0 - - &:last-of-type - right 0 - - > .calendar - display flex - flex-wrap wrap - padding 16px - - * - user-select none - - > div - width calc(100% * (1/7)) - text-align center - line-height 32px - font-size 14px - - &.weekday - color var(--calendarWeek) - - &[data-is-weekend] - color var(--calendarSaturdayOrSunday) - - &[data-today] - box-shadow 0 0 0 var(--lineWidth) var(--calendarWeek) inset - border-radius 6px - - &[data-is-weekend] - box-shadow 0 0 0 var(--lineWidth) var(--calendarSaturdayOrSunday) inset - - &.day - cursor pointer - color var(--calendarDay) - - > div - border-radius 6px - - &:hover > div - background var(--faceClearButtonHover) - - &:active > div - background var(--faceClearButtonActive) - - &[data-is-weekend] - color var(--calendarSaturdayOrSunday) - - &[data-is-out-of-range] - cursor default - opacity 0.5 - - &[data-selected] - font-weight bold - - > div - background var(--faceClearButtonHover) - - &:active > div - background var(--faceClearButtonActive) - - &[data-today] - > div - color var(--primaryForeground) - background var(--primary) - - &:hover > div - background var(--primaryLighten10) - - &:active > div - background var(--primaryDarken10) - -</style> diff --git a/src/client/app/desktop/views/components/choose-file-from-drive-window.vue b/src/client/app/desktop/views/components/choose-file-from-drive-window.vue deleted file mode 100644 index 71c430edeb..0000000000 --- a/src/client/app/desktop/views/components/choose-file-from-drive-window.vue +++ /dev/null @@ -1,136 +0,0 @@ -<template> -<mk-window ref="window" is-modal width="800px" height="500px" @closed="destroyDom"> - <template #header> - <span class="jqiaciqv"> - <span class="title">{{ $t('choose-prompt') }}</span> - <span class="count" v-if="multiple && files.length > 0">({{ $t('chosen-files', { count: files.length }) }})</span> - </span> - </template> - - <div class="rqsvbumu"> - <x-drive - ref="browser" - class="browser" - :type="type" - :multiple="multiple" - @selected="onSelected" - @change-selection="onChangeSelection" - /> - <div class="footer"> - <button class="upload" :title="$t('title')" @click="upload"><fa icon="upload"/></button> - <ui-button inline @click="cancel" style="margin-right:16px;">{{ $t('cancel') }}</ui-button> - <ui-button inline primary :disabled="multiple && files.length == 0" @click="ok">{{ $t('ok') }}</ui-button> - </div> - </div> -</mk-window> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -export default Vue.extend({ - i18n: i18n('desktop/views/components/choose-file-from-drive-window.vue'), - components: { - XDrive: () => import('./drive.vue').then(m => m.default), - }, - props: { - type: { - type: String, - required: false, - default: undefined - }, - multiple: { - default: false - } - }, - data() { - return { - files: [] - }; - }, - methods: { - onSelected(file) { - this.files = [file]; - this.ok(); - }, - onChangeSelection(files) { - this.files = files; - }, - upload() { - (this.$refs.browser as any).selectLocalFile(); - }, - ok() { - this.$emit('selected', this.multiple ? this.files : this.files[0]); - (this.$refs.window as any).close(); - }, - cancel() { - (this.$refs.window as any).close(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.jqiaciqv - .title - > [data-icon] - margin-right 4px - - .count - margin-left 8px - opacity 0.7 - -.rqsvbumu - display flex - flex-direction column - height 100% - - .browser - flex 1 - overflow auto - - .footer - padding 16px - background var(--desktopPostFormBg) - text-align right - - .upload - display inline-block - position absolute - top 8px - left 16px - cursor pointer - padding 0 - margin 8px 4px 0 0 - width 40px - height 40px - font-size 1em - color var(--primaryAlpha05) - background transparent - outline none - border solid 1px transparent - border-radius 4px - - &:hover - background transparent - border-color var(--primaryAlpha03) - - &:active - color var(--primaryAlpha06) - background transparent - border-color var(--primaryAlpha05) - //box-shadow 0 2px 4px rgba(var(--primaryDarken50), 0.15) inset - - &:focus - &:after - content "" - pointer-events none - position absolute - top -5px - right -5px - bottom -5px - left -5px - border 2px solid var(--primaryAlpha03) - border-radius 8px - -</style> diff --git a/src/client/app/desktop/views/components/choose-folder-from-drive-window.vue b/src/client/app/desktop/views/components/choose-folder-from-drive-window.vue deleted file mode 100644 index fe76436544..0000000000 --- a/src/client/app/desktop/views/components/choose-folder-from-drive-window.vue +++ /dev/null @@ -1,56 +0,0 @@ -<template> -<mk-window ref="window" is-modal width="800px" height="500px" @closed="destroyDom"> - <template #header> - <span>{{ $t('choose-prompt') }}</span> - </template> - - <div class="hllkpxxu"> - <x-drive - ref="browser" - class="browser" - :multiple="false" - /> - <div class="footer"> - <ui-button inline @click="cancel" style="margin-right:16px;">{{ $t('cancel') }}</ui-button> - <ui-button inline @click="ok" primary>{{ $t('ok') }}</ui-button> - </div> - </div> -</mk-window> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -export default Vue.extend({ - i18n: i18n('desktop/views/components/choose-folder-from-drive-window.vue'), - components: { - XDrive: () => import('./drive.vue').then(m => m.default), - }, - methods: { - ok() { - this.$emit('selected', (this.$refs.browser as any).folder); - (this.$refs.window as any).close(); - }, - cancel() { - (this.$refs.window as any).close(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.hllkpxxu - display flex - flex-direction column - height 100% - - .browser - flex 1 - overflow auto - - .footer - padding 16px - background var(--desktopPostFormBg) - text-align right - -</style> diff --git a/src/client/app/desktop/views/components/context-menu.menu.vue b/src/client/app/desktop/views/components/context-menu.menu.vue deleted file mode 100644 index f2bb3bec23..0000000000 --- a/src/client/app/desktop/views/components/context-menu.menu.vue +++ /dev/null @@ -1,121 +0,0 @@ -<template> -<ul class="menu"> - <li v-for="(item, i) in menu" :class="item ? item.type : item === null ? 'divider' : null"> - <template v-if="item"> - <template v-if="item.type == null || item.type == 'item'"> - <p @click="click(item)"><i v-if="item.icon" :class="$style.icon"><fa :icon="item.icon"/></i>{{ item.text }}</p> - </template> - <template v-else-if="item.type == 'link'"> - <a :href="item.href" :target="item.target" @click="click(item)" :download="item.download"><i v-if="item.icon" :class="$style.icon"><fa :icon="item.icon"/></i>{{ item.text }}</a> - </template> - <template v-else-if="item.type == 'nest'"> - <p><i v-if="item.icon" :class="$style.icon"><fa :icon="item.icon"/></i>{{ item.text }}...<span class="caret"><fa icon="caret-right"/></span></p> - <me-nu :menu="item.menu" @x="click"/> - </template> - </template> - </li> -</ul> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - name: 'me-nu', - props: ['menu'], - methods: { - click(item) { - this.$emit('x', item); - } - } -}); -</script> - -<style lang="stylus" scoped> -.menu - $width = 240px - $item-height = 38px - $padding = 10px - - margin 0 - padding $padding 0 - list-style none - - li - display block - - &.divider - margin-top $padding - padding-top $padding - border-top solid var(--lineWidth) var(--faceDivider) - - &.nest - > p - cursor default - - > .caret - position absolute - top 0 - right 8px - - > * - line-height $item-height - width 28px - text-align center - - &:hover > ul - visibility visible - - &:active - > p, a - background var(--primary) - - > p, a - display block - z-index 1 - margin 0 - padding 0 32px 0 38px - line-height $item-height - color var(--text) - text-decoration none - cursor pointer - - &:hover - text-decoration none - - * - pointer-events none - - &:hover - > p, a - text-decoration none - background var(--primary) - color var(--primaryForeground) - - &:active - > p, a - text-decoration none - background var(--primaryDarken10) - color var(--primaryForeground) - - li > ul - visibility hidden - position absolute - top 0 - left $width - margin-top -($padding) - width $width - background var(--popupBg) - border-radius 0 4px 4px 4px - box-shadow 2px 2px 8px rgba(#000, 0.2) - transition visibility 0s linear 0.2s - -</style> - -<style lang="stylus" module> -.icon - display inline-block - width 28px - margin-left -28px - text-align center -</style> - diff --git a/src/client/app/desktop/views/components/context-menu.vue b/src/client/app/desktop/views/components/context-menu.vue deleted file mode 100644 index e79536fc0f..0000000000 --- a/src/client/app/desktop/views/components/context-menu.vue +++ /dev/null @@ -1,90 +0,0 @@ -<template> -<div class="context-menu" @contextmenu.prevent="() => {}"> - <x-menu :menu="menu" @x="click"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import anime from 'animejs'; -import contains from '../../../common/scripts/contains'; -import XMenu from './context-menu.menu.vue'; - -export default Vue.extend({ - components: { - XMenu - }, - props: ['x', 'y', 'menu'], - mounted() { - this.$nextTick(() => { - const width = this.$el.offsetWidth; - const height = this.$el.offsetHeight; - - let x = this.x; - let y = this.y; - - if (x + width - window.pageXOffset > window.innerWidth) { - x = window.innerWidth - width + window.pageXOffset; - } - - if (y + height - window.pageYOffset > window.innerHeight) { - y = window.innerHeight - height + window.pageYOffset; - } - - this.$el.style.left = x + 'px'; - this.$el.style.top = y + 'px'; - - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.addEventListener('mousedown', this.onMousedown); - } - - this.$el.style.display = 'block'; - - anime({ - targets: this.$el, - opacity: [0, 1], - duration: 100, - easing: 'linear' - }); - }); - }, - methods: { - onMousedown(e) { - e.preventDefault(); - if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); - return false; - }, - click(item) { - if (item.action) item.action(); - this.close(); - }, - close() { - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.removeEventListener('mousedown', this.onMousedown); - } - - this.$emit('closed'); - this.destroyDom(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.context-menu - $width = 240px - $item-height = 38px - $padding = 10px - - position fixed - top 0 - left 0 - z-index 4096 - width $width - font-size 0.8em - background var(--popupBg) - border-radius 0 4px 4px 4px - box-shadow 2px 2px 8px rgba(#000, 0.2) - opacity 0 - -</style> diff --git a/src/client/app/desktop/views/components/crop-window.vue b/src/client/app/desktop/views/components/crop-window.vue deleted file mode 100644 index 856f889b02..0000000000 --- a/src/client/app/desktop/views/components/crop-window.vue +++ /dev/null @@ -1,189 +0,0 @@ -<template> - <mk-window ref="window" is-modal width="800px" :can-close="false"> - <template #header><fa icon="crop"/>{{ title }}</template> - <div class="body"> - <vue-cropper ref="cropper" - :src="imageUrl" - :view-mode="1" - :aspect-ratio="aspectRatio" - :container-style="{ width: '100%', 'max-height': '400px' }" - /> - </div> - <div :class="$style.actions"> - <button :class="$style.skip" @click="skip">{{ $t('skip') }}</button> - <button :class="$style.cancel" @click="cancel">{{ $t('cancel') }}</button> - <button :class="$style.ok" @click="ok">{{ $t('ok') }}</button> - </div> - </mk-window> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import VueCropper from 'vue-cropperjs'; -import 'cropperjs/dist/cropper.css'; -import * as url from '../../../../../prelude/url'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/crop-window.vue'), - components: { - VueCropper - }, - props: { - image: { - type: Object, - required: true - }, - title: { - type: String, - required: true - }, - aspectRatio: { - type: Number, - required: true - } - }, - computed: { - imageUrl() { - return `/proxy/?${url.query({ - url: this.image.url - })}`; - }, - }, - methods: { - ok() { - (this.$refs.cropper as any).getCroppedCanvas().toBlob(blob => { - this.$emit('cropped', blob); - (this.$refs.window as any).close(); - }); - }, - - skip() { - this.$emit('skipped'); - (this.$refs.window as any).close(); - }, - - cancel() { - this.$emit('canceled'); - (this.$refs.window as any).close(); - } - } -}); -</script> - -<style lang="stylus" module> - - -.header - > [data-icon] - margin-right 4px - -.img - width 100% - max-height 400px - -.actions - height 72px - background var(--primaryLighten95) - -.ok -.cancel -.skip - display block - position absolute - bottom 16px - cursor pointer - padding 0 - margin 0 - height 40px - font-size 1em - outline none - border-radius 4px - - &:focus - &:after - content "" - pointer-events none - position absolute - top -5px - right -5px - bottom -5px - left -5px - border 2px solid var(--primaryAlpha03) - border-radius 8px - - &:disabled - opacity 0.7 - cursor default - -.ok -.cancel - width 120px - -.ok - right 16px - color var(--primaryForeground) - background linear-gradient(to bottom, var(--primaryLighten25) 0%, var(--primaryLighten10) 100%) - border solid 1px var(--primaryLighten15) - - &:not(:disabled) - font-weight bold - - &:hover:not(:disabled) - background linear-gradient(to bottom, var(--primaryLighten8) 0%, var(--primaryDarken8) 100%) - border-color var(--primary) - - &:active:not(:disabled) - background var(--primary) - border-color var(--primary) - -.cancel -.skip - color #888 - background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) - border solid 1px #e2e2e2 - - &:hover - background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) - border-color #dcdcdc - - &:active - background #ececec - border-color #dcdcdc - -.cancel - right 148px - -.skip - left 16px - width 150px - -</style> - -<style lang="stylus"> -.cropper-modal { - opacity: 0.8; -} - -.cropper-view-box { - outline-color: var(--primary); -} - -.cropper-line, .cropper-point { - background-color: var(--primary); -} - -.cropper-bg { - animation: cropper-bg 0.5s linear infinite; -} - -@keyframes cropper-bg { - 0% { - background-position: 0 0; - } - - 100% { - background-position: -8px -8px; - } -} -</style> diff --git a/src/client/app/desktop/views/components/detail-notes.vue b/src/client/app/desktop/views/components/detail-notes.vue deleted file mode 100644 index e50dda7c6f..0000000000 --- a/src/client/app/desktop/views/components/detail-notes.vue +++ /dev/null @@ -1,56 +0,0 @@ -<template> -<div class="ecsvsegy" v-if="!fetching"> - <sequential-entrance animation="entranceFromTop" delay="25"> - <template v-for="note in notes"> - <mk-note-detail class="post" :note="note" :key="note.id"/> - </template> - </sequential-entrance> - <div class="more" v-if="more"> - <ui-button inline @click="fetchMore()">{{ $t('@.load-more') }}</ui-button> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import paging from '../../../common/scripts/paging'; - -export default Vue.extend({ - i18n: i18n(), - - mixins: [ - paging({ - captureWindowScroll: true, - }), - ], - - props: { - pagination: { - required: true - }, - extract: { - required: false - } - }, - - computed: { - notes() { - return this.extract ? this.extract(this.items) : this.items; - } - } -}); -</script> - -<style lang="stylus" scoped> -.ecsvsegy - margin 0 auto - - > * > .post - margin-bottom 16px - - > .more - margin 32px 16px 16px 16px - text-align center - -</style> diff --git a/src/client/app/desktop/views/components/drive-window.vue b/src/client/app/desktop/views/components/drive-window.vue deleted file mode 100644 index 5f8a9316f3..0000000000 --- a/src/client/app/desktop/views/components/drive-window.vue +++ /dev/null @@ -1,61 +0,0 @@ -<template> -<mk-window ref="window" @closed="destroyDom" width="800px" height="500px" :popout-url="popout"> - <template #header> - <p v-if="usage" :class="$style.info"><b>{{ usage.toFixed(1) }}%</b> {{ $t('used') }}</p> - <span :class="$style.title"><fa icon="cloud"/>{{ $t('@.drive') }}</span> - </template> - <x-drive :class="$style.browser" multiple :init-folder="folder" ref="browser"/> -</mk-window> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { url } from '../../../config'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/drive-window.vue'), - components: { - XDrive: () => import('./drive.vue').then(m => m.default), - }, - props: ['folder'], - data() { - return { - usage: null - }; - }, - mounted() { - this.$root.api('drive').then(info => { - this.usage = info.usage / info.capacity * 100; - }); - }, - methods: { - popout() { - const folder = (this.$refs.browser as any) ? (this.$refs.browser as any).folder : null; - if (folder) { - return `${url}/i/drive/folder/${folder.id}`; - } else { - return `${url}/i/drive`; - } - } - } -}); -</script> - -<style lang="stylus" module> -.title - > [data-icon] - margin-right 4px - -.info - position absolute - top 0 - left 16px - margin 0 - font-size 80% - -.browser - height 100% - -</style> - diff --git a/src/client/app/desktop/views/components/drive.file.vue b/src/client/app/desktop/views/components/drive.file.vue deleted file mode 100644 index e34fdff423..0000000000 --- a/src/client/app/desktop/views/components/drive.file.vue +++ /dev/null @@ -1,339 +0,0 @@ -<template> -<div class="gvfdktuvdgwhmztnuekzkswkjygptfcv" - :data-is-selected="isSelected" - :data-is-contextmenu-showing="isContextmenuShowing" - @click="onClick" - draggable="true" - @dragstart="onDragstart" - @dragend="onDragend" - @contextmenu.prevent.stop="onContextmenu" - :title="title" -> - <div class="label" v-if="$store.state.i.avatarId == file.id"> - <img src="/assets/label.svg"/> - <p>{{ $t('avatar') }}</p> - </div> - <div class="label" v-if="$store.state.i.bannerId == file.id"> - <img src="/assets/label.svg"/> - <p>{{ $t('banner') }}</p> - </div> - <div class="label red" v-if="file.isSensitive"> - <img src="/assets/label-red.svg"/> - <p>{{ $t('nsfw') }}</p> - </div> - - <x-file-thumbnail class="thumbnail" :file="file" fit="contain"/> - - <p class="name"> - <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> - <span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> - </p> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import copyToClipboard from '../../../common/scripts/copy-to-clipboard'; -import updateAvatar from '../../api/update-avatar'; -import updateBanner from '../../api/update-banner'; -import XFileThumbnail from '../../../common/views/components/drive-file-thumbnail.vue'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/drive.file.vue'), - props: ['file'], - components: { - XFileThumbnail - }, - data() { - return { - isContextmenuShowing: false, - isDragging: false - }; - }, - computed: { - browser(): any { - return this.$parent; - }, - isSelected(): boolean { - return this.browser.selectedFiles.some(f => f.id == this.file.id); - }, - title(): string { - return `${this.file.name}\n${this.file.type} ${Vue.filter('bytes')(this.file.size)}`; - } - }, - methods: { - onClick() { - this.browser.chooseFile(this.file); - }, - - onContextmenu(e) { - this.isContextmenuShowing = true; - this.$contextmenu(e, [{ - type: 'item', - text: this.$t('contextmenu.rename'), - icon: 'i-cursor', - action: this.rename - }, { - type: 'item', - text: this.file.isSensitive ? this.$t('contextmenu.unmark-as-sensitive') : this.$t('contextmenu.mark-as-sensitive'), - icon: this.file.isSensitive ? ['far', 'eye'] : ['far', 'eye-slash'], - action: this.toggleSensitive - }, null, { - type: 'item', - text: this.$t('contextmenu.copy-url'), - icon: 'link', - action: this.copyUrl - }, { - type: 'link', - href: this.file.url, - target: '_blank', - text: this.$t('contextmenu.download'), - icon: 'download', - download: this.file.name - }, null, { - type: 'item', - text: this.$t('@.delete'), - icon: ['far', 'trash-alt'], - action: this.deleteFile - }, null, { - type: 'nest', - text: this.$t('contextmenu.else-files'), - menu: [{ - type: 'item', - text: this.$t('contextmenu.set-as-avatar'), - action: this.setAsAvatar - }, { - type: 'item', - text: this.$t('contextmenu.set-as-banner'), - action: this.setAsBanner - }] - }, /*{ - type: 'nest', - text: this.$t('contextmenu.open-in-app'), - menu: [{ - type: 'item', - text: '%i18n:@contextmenu.add-app%...', - action: this.addApp - }] - }*/], { - closed: () => { - this.isContextmenuShowing = false; - } - }); - }, - - onDragstart(e) { - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('mk_drive_file', JSON.stringify(this.file)); - this.isDragging = true; - - // 親ブラウザに対して、ドラッグが開始されたフラグを立てる - // (=あなたの子供が、ドラッグを開始しましたよ) - this.browser.isDragSource = true; - }, - - onDragend(e) { - this.isDragging = false; - this.browser.isDragSource = false; - }, - - onThumbnailLoaded() { - if (this.file.properties.avgColor) { - anime({ - targets: this.$refs.thumbnail, - backgroundColor: 'transparent', // TODO fade - duration: 100, - easing: 'linear' - }); - } - }, - - rename() { - this.$root.dialog({ - title: this.$t('contextmenu.rename-file'), - input: { - placeholder: this.$t('contextmenu.input-new-file-name'), - default: this.file.name, - allowEmpty: false - } - }).then(({ canceled, result: name }) => { - if (canceled) return; - this.$root.api('drive/files/update', { - fileId: this.file.id, - name: name - }); - }); - }, - - toggleSensitive() { - this.$root.api('drive/files/update', { - fileId: this.file.id, - isSensitive: !this.file.isSensitive - }); - }, - - copyUrl() { - copyToClipboard(this.file.url); - this.$root.dialog({ - title: this.$t('contextmenu.copied'), - text: this.$t('contextmenu.copied-url-to-clipboard') - }); - }, - - setAsAvatar() { - updateAvatar(this.$root)(this.file); - }, - - setAsBanner() { - updateBanner(this.$root)(this.file); - }, - - addApp() { - alert('not implemented yet'); - }, - - deleteFile() { - this.$root.api('drive/files/delete', { - fileId: this.file.id - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.gvfdktuvdgwhmztnuekzkswkjygptfcv - padding 8px 0 0 0 - min-height 180px - border-radius 4px - - &, * - cursor pointer - - &:hover - background rgba(#000, 0.05) - - > .label - &:before - &:after - background #0b65a5 - - &.red - &:before - &:after - background #c12113 - - &:active - background rgba(#000, 0.1) - - > .label - &:before - &:after - background #0b588c - - &.red - &:before - &:after - background #ce2212 - - &[data-is-selected] - background var(--primary) - - &:hover - background var(--primaryLighten10) - - &:active - background var(--primaryDarken10) - - > .label - &:before - &:after - display none - - > .name - color var(--primaryForeground) - - > .thumbnail - color var(--primaryForeground) - - &[data-is-contextmenu-showing] - &:after - content "" - pointer-events none - position absolute - top -4px - right -4px - bottom -4px - left -4px - border 2px dashed var(--primaryAlpha03) - border-radius 4px - - > .label - position absolute - top 0 - left 0 - pointer-events none - - &:before - &:after - content "" - display block - position absolute - z-index 1 - background #0c7ac9 - - &:before - top 0 - left 57px - width 28px - height 8px - - &:after - top 57px - left 0 - width 8px - height 28px - - &.red - &:before - &:after - background #c12113 - - > img - position absolute - z-index 2 - top 0 - left 0 - - > p - position absolute - z-index 3 - top 19px - left -28px - width 120px - margin 0 - text-align center - line-height 28px - color #fff - transform rotate(-45deg) - - > .thumbnail - width 128px - height 128px - margin auto - color var(--driveFileIcon) - - > .name - display block - margin 4px 0 0 0 - font-size 0.8em - text-align center - word-break break-all - color var(--text) - overflow hidden - - > .ext - opacity 0.5 - -</style> diff --git a/src/client/app/desktop/views/components/emoji-picker-dialog.vue b/src/client/app/desktop/views/components/emoji-picker-dialog.vue deleted file mode 100644 index 4ea0f441a9..0000000000 --- a/src/client/app/desktop/views/components/emoji-picker-dialog.vue +++ /dev/null @@ -1,84 +0,0 @@ -<template> -<div class="gcafiosrssbtbnbzqupfmglvzgiaipyv"> - <x-picker @chosen="chosen"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import contains from '../../../common/scripts/contains'; - -export default Vue.extend({ - components: { - XPicker: () => import('../../../common/views/components/emoji-picker.vue').then(m => m.default) - }, - - props: { - x: { - type: Number, - required: true - }, - y: { - type: Number, - required: true - } - }, - - mounted() { - this.$nextTick(() => { - const width = this.$el.offsetWidth; - const height = this.$el.offsetHeight; - - let x = this.x; - let y = this.y; - - if (x + width - window.pageXOffset > window.innerWidth) { - x = window.innerWidth - width + window.pageXOffset; - } - - if (y + height - window.pageYOffset > window.innerHeight) { - y = window.innerHeight - height + window.pageYOffset; - } - - this.$el.style.left = x + 'px'; - this.$el.style.top = y + 'px'; - - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.addEventListener('mousedown', this.onMousedown); - } - }); - }, - - methods: { - onMousedown(e) { - e.preventDefault(); - if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); - return false; - }, - - chosen(emoji) { - this.$emit('chosen', emoji); - this.close(); - }, - - close() { - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.removeEventListener('mousedown', this.onMousedown); - } - - this.$emit('closed'); - this.destroyDom(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.gcafiosrssbtbnbzqupfmglvzgiaipyv - position absolute - top 0 - left 0 - z-index 3000 - box-shadow 0 2px 12px 0 rgba(0, 0, 0, 0.3) - -</style> diff --git a/src/client/app/desktop/views/components/game-window.vue b/src/client/app/desktop/views/components/game-window.vue deleted file mode 100644 index 3dba4c3af4..0000000000 --- a/src/client/app/desktop/views/components/game-window.vue +++ /dev/null @@ -1,38 +0,0 @@ -<template> -<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="destroyDom"> - <template #header><fa icon="gamepad"/> {{ $t('game') }}</template> - <x-reversi :class="$style.content" @gamed="g => game = g"/> -</mk-window> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { url } from '../../../config'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/game-window.vue'), - components: { - XReversi: () => import('../../../common/views/components/games/reversi/reversi.vue').then(m => m.default) - }, - data() { - return { - game: null - }; - }, - computed: { - popout(): string { - return this.game - ? `${url}/games/reversi/${this.game.id}` - : `${url}/games/reversi`; - } - } -}); -</script> - -<style lang="stylus" module> -.content - height 100% - overflow auto - -</style> diff --git a/src/client/app/desktop/views/components/index.ts b/src/client/app/desktop/views/components/index.ts deleted file mode 100644 index 0cc44e1bbd..0000000000 --- a/src/client/app/desktop/views/components/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -import Vue from 'vue'; - -import ui from './ui.vue'; -import uiNotification from './ui-notification.vue'; -import note from './note.vue'; -import notes from './notes.vue'; -import subNoteContent from './sub-note-content.vue'; -import window from './window.vue'; -import renoteFormWindow from './renote-form-window.vue'; -import mediaVideo from './media-video.vue'; -import notifications from './notifications.vue'; -import renoteForm from './renote-form.vue'; -import notePreview from './note-preview.vue'; -import noteDetail from './note-detail.vue'; -import calendar from './calendar.vue'; -import activity from './activity.vue'; -import userListTimeline from './user-list-timeline.vue'; -import uiContainer from './ui-container.vue'; - -Vue.component('mk-ui', ui); -Vue.component('mk-ui-notification', uiNotification); -Vue.component('mk-note', note); -Vue.component('mk-notes', notes); -Vue.component('mk-sub-note-content', subNoteContent); -Vue.component('mk-window', window); -Vue.component('mk-renote-form-window', renoteFormWindow); -Vue.component('mk-media-video', mediaVideo); -Vue.component('mk-notifications', notifications); -Vue.component('mk-renote-form', renoteForm); -Vue.component('mk-note-preview', notePreview); -Vue.component('mk-note-detail', noteDetail); -Vue.component('mk-calendar', calendar); -Vue.component('mk-activity', activity); -Vue.component('mk-user-list-timeline', userListTimeline); -Vue.component('ui-container', uiContainer); diff --git a/src/client/app/desktop/views/components/media-video-dialog.vue b/src/client/app/desktop/views/components/media-video-dialog.vue deleted file mode 100644 index 9d2d0527ef..0000000000 --- a/src/client/app/desktop/views/components/media-video-dialog.vue +++ /dev/null @@ -1,47 +0,0 @@ -<template> -<ui-modal v-hotkey.global="keymap"> - <video :src="video.url" :title="video.name" controls autoplay ref="video" @volumechange="volumechange" /> -</ui-modal> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: ['video', 'start'], - mounted() { - const videoTag = this.$refs.video as HTMLVideoElement; - if (this.start) videoTag.currentTime = this.start - videoTag.volume = this.$store.state.device.mediaVolume; - }, - computed: { - keymap(): any { - return { - 'esc': this.close, - }; - } - }, - methods: { - close() { - }, - volumechange() { - const videoTag = this.$refs.video as HTMLVideoElement; - this.$store.commit('device/set', { key: 'mediaVolume', value: videoTag.volume }); - }, - } -}); -</script> - -<style lang="stylus" scoped> -video - position fixed - z-index 2 - top 0 - right 0 - bottom 0 - left 0 - max-width 80vw - max-height 80vh - margin auto - -</style> diff --git a/src/client/app/desktop/views/components/media-video.vue b/src/client/app/desktop/views/components/media-video.vue deleted file mode 100644 index c53da0f49e..0000000000 --- a/src/client/app/desktop/views/components/media-video.vue +++ /dev/null @@ -1,102 +0,0 @@ -<template> -<div class="uofhebxjdgksfmltszlxurtjnjjsvioh" v-if="video.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false"> - <div> - <b><fa icon="exclamation-triangle"/> {{ $t('sensitive') }}</b> - <span>{{ $t('click-to-show') }}</span> - </div> -</div> -<div class="vwxdhznewyashiknzolsoihtlpicqepe" v-else> - <a class="thumbnail" - :href="video.url" - :style="imageStyle" - @click.prevent="onClick" - :title="video.name" - > - <fa :icon="['far', 'play-circle']"/> - </a> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import MkMediaVideoDialog from './media-video-dialog.vue'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/media-video.vue'), - props: { - video: { - type: Object, - required: true - }, - inlinePlayable: { - default: false - } - }, - data() { - return { - hide: true - }; - }, - computed: { - imageStyle(): any { - return { - 'background-image': `url(${this.video.thumbnailUrl})` - }; - } - }, - methods: { - onClick() { - const videoTag = this.$refs.video as (HTMLVideoElement | null) - var start = 0 - if (videoTag) { - start = videoTag.currentTime - videoTag.pause() - } - const viewer = this.$root.new(MkMediaVideoDialog, { - video: this.video, - start, - }); - this.$once('hook:beforeDestroy', () => { - viewer.close(); - }); - } - } -}) -</script> - -<style lang="stylus" scoped> -.vwxdhznewyashiknzolsoihtlpicqepe - .video - display block - width 100% - height 100% - border-radius 4px - - .thumbnail - display flex - justify-content center - align-items center - font-size 3.5em - cursor zoom-in - overflow hidden - background-position center - background-size cover - width 100% - height 100% - -.uofhebxjdgksfmltszlxurtjnjjsvioh - display flex - justify-content center - align-items center - background #111 - color #fff - - > div - display table-cell - text-align center - font-size 12px - - > b - display block -</style> diff --git a/src/client/app/desktop/views/components/messaging-room-window.vue b/src/client/app/desktop/views/components/messaging-room-window.vue deleted file mode 100644 index 6c1708b59f..0000000000 --- a/src/client/app/desktop/views/components/messaging-room-window.vue +++ /dev/null @@ -1,37 +0,0 @@ -<template> -<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="destroyDom"> - <template #header><fa icon="comments"/> {{ $t('@.messaging') }}: <mk-user-name v-if="user" :user="user"/><span v-else>{{ group.name }}</span></template> - <x-messaging-room :user="user" :group="group" :class="$style.content"/> -</mk-window> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { url } from '../../../config'; -import getAcct from '../../../../../misc/acct/render'; - -export default Vue.extend({ - i18n: i18n(), - components: { - XMessagingRoom: () => import('../../../common/views/components/messaging-room.vue').then(m => m.default) - }, - props: ['user', 'group'], - computed: { - popout(): string { - if (this.user) { - return `${url}/i/messaging/${getAcct(this.user)}`; - } else if (this.group) { - return `${url}/i/messaging/group/${this.group.id}`; - } - } - } -}); -</script> - -<style lang="stylus" module> -.content - height 100% - overflow auto - -</style> diff --git a/src/client/app/desktop/views/components/messaging-window.vue b/src/client/app/desktop/views/components/messaging-window.vue deleted file mode 100644 index 7cec9484d6..0000000000 --- a/src/client/app/desktop/views/components/messaging-window.vue +++ /dev/null @@ -1,42 +0,0 @@ -<template> -<mk-window ref="window" width="500px" height="560px" @closed="destroyDom"> - <template #header :class="$style.header"><fa icon="comments"/>{{ $t('@.messaging') }}</template> - <x-messaging :class="$style.content" @navigate="navigate" @navigateGroup="navigateGroup"/> -</mk-window> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import MkMessagingRoomWindow from './messaging-room-window.vue'; - -export default Vue.extend({ - i18n: i18n(), - components: { - XMessaging: () => import('../../../common/views/components/messaging.vue').then(m => m.default) - }, - methods: { - navigate(user) { - this.$root.new(MkMessagingRoomWindow, { - user: user - }); - }, - navigateGroup(group) { - this.$root.new(MkMessagingRoomWindow, { - group: group - }); - } - } -}); -</script> - -<style lang="stylus" module> -.header - > [data-icon] - margin-right 4px - -.content - height 100% - overflow auto - -</style> diff --git a/src/client/app/desktop/views/components/note-detail.vue b/src/client/app/desktop/views/components/note-detail.vue deleted file mode 100644 index e0ce5ce1c6..0000000000 --- a/src/client/app/desktop/views/components/note-detail.vue +++ /dev/null @@ -1,356 +0,0 @@ -<template> -<div class="mk-note-detail" :title="title" tabindex="-1" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> - <button - class="read-more" - v-if="appearNote.reply && appearNote.reply.replyId && conversation.length == 0" - :title="$t('title')" - @click="fetchConversation" - :disabled="conversationFetching" - > - <template v-if="!conversationFetching"><fa icon="ellipsis-v"/></template> - <template v-if="conversationFetching"><fa icon="spinner" pulse/></template> - </button> - <div class="conversation"> - <x-sub v-for="note in conversation" :key="note.id" :note="note"/> - </div> - <div class="reply-to" v-if="appearNote.reply"> - <x-sub :note="appearNote.reply"/> - </div> - <mk-renote class="renote" v-if="isRenote" :note="note"/> - <article> - <mk-avatar class="avatar" :user="appearNote.user"/> - <header> - <router-link class="name" :to="appearNote.user | userPage" v-user-preview="appearNote.user.id"> - <mk-user-name :user="appearNote.user"/> - </router-link> - <span class="username"><mk-acct :user="appearNote.user"/></span> - <div class="info"> - <router-link class="time" :to="appearNote | notePage"> - <mk-time :time="appearNote.createdAt"/> - </router-link> - <div class="visibility-info"> - <span class="visibility" v-if="appearNote.visibility != 'public'"> - <fa v-if="appearNote.visibility == 'home'" icon="home"/> - <fa v-if="appearNote.visibility == 'followers'" icon="unlock"/> - <fa v-if="appearNote.visibility == 'specified'" icon="envelope"/> - </span> - <span class="localOnly" v-if="appearNote.localOnly == true"><fa icon="heart"/></span> - </div> - </div> - </header> - <div class="body"> - <p v-if="appearNote.cw != null" class="cw"> - <mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" /> - <mk-cw-button v-model="showContent" :note="appearNote"/> - </p> - <div class="content" v-show="appearNote.cw == null || showContent"> - <div class="text"> - <span v-if="appearNote.isHidden" style="opacity: 0.5">{{ $t('private') }}</span> - <span v-if="appearNote.deletedAt" style="opacity: 0.5">{{ $t('deleted') }}</span> - <mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" /> - </div> - <div class="files" v-if="appearNote.files.length > 0"> - <mk-media-list :media-list="appearNote.files" :raw="true"/> - </div> - <mk-poll v-if="appearNote.poll" :note="appearNote"/> - <mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/> - <a class="location" v-if="appearNote.geo" :href="`https://maps.google.com/maps?q=${appearNote.geo.coordinates[1]},${appearNote.geo.coordinates[0]}`" rel="noopener" target="_blank"><fa icon="map-marker-alt"/> {{ $t('location') }}</a> - <div class="map" v-if="appearNote.geo" ref="map"></div> - <div class="renote" v-if="appearNote.renote"> - <mk-note-preview :note="appearNote.renote"/> - </div> - </div> - </div> - <footer> - <span class="app" v-if="note.app && $store.state.settings.showVia">via <b>{{ note.app.name }}</b></span> - <mk-reactions-viewer :note="appearNote"/> - <button class="replyButton" @click="reply()" :title="$t('reply')"> - <template v-if="appearNote.reply"><fa icon="reply-all"/></template> - <template v-else><fa icon="reply"/></template> - <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> - </button> - <button v-if="['public', 'home'].includes(appearNote.visibility)" class="renoteButton" @click="renote()" :title="$t('renote')"> - <fa icon="retweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> - </button> - <button v-else class="inhibitedButton"> - <fa icon="ban"/> - </button> - <button v-if="!isMyNote && appearNote.myReaction == null" class="reactionButton" @click="react()" ref="reactButton" :title="$t('add-reaction')"> - <fa icon="plus"/> - </button> - <button v-if="!isMyNote && appearNote.myReaction != null" class="reactionButton reacted" @click="undoReact(appearNote)" ref="reactButton" :title="$t('undo-reaction')"> - <fa icon="minus"/> - </button> - <button @click="menu()" ref="menuButton"> - <fa icon="ellipsis-h"/> - </button> - </footer> - </article> - <div class="replies" v-if="!compact"> - <x-sub v-for="note in replies" :key="note.id" :note="note"/> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XSub from './note.sub.vue'; -import noteSubscriber from '../../../common/scripts/note-subscriber'; -import noteMixin from '../../../common/scripts/note-mixin'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/note-detail.vue'), - - components: { - XSub - }, - - mixins: [noteMixin(), noteSubscriber('note')], - - props: { - note: { - type: Object, - required: true - }, - compact: { - default: false - } - }, - - data() { - return { - conversation: [], - conversationFetching: false, - replies: [] - }; - }, - - mounted() { - // Get replies - if (!this.compact) { - this.$root.api('notes/children', { - noteId: this.appearNote.id, - limit: 30 - }).then(replies => { - this.replies = replies; - }); - } - }, - - methods: { - fetchConversation() { - this.conversationFetching = true; - - // Fetch conversation - this.$root.api('notes/conversation', { - noteId: this.appearNote.replyId - }).then(conversation => { - this.conversationFetching = false; - this.conversation = conversation.reverse(); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-note-detail - overflow hidden - text-align left - background var(--face) - - &.round - border-radius 6px - - > .read-more - border-radius 6px 6px 0 0 - - &.shadow - box-shadow 0 3px 8px rgba(0, 0, 0, 0.2) - - > .read-more - display block - margin 0 - padding 10px 0 - width 100% - font-size 1em - text-align center - color #999 - cursor pointer - background var(--subNoteBg) - outline none - border none - border-bottom solid 1px var(--faceDivider) - - &:hover - box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05) - - &:active - box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1) - - &:disabled - cursor wait - - > .conversation - > * - border-bottom 1px solid var(--faceDivider) - - > .renote + article - padding-top 8px - - > .reply-to - border-bottom 1px solid var(--faceDivider) - - > article - padding 28px 32px 18px 32px - - &:after - content "" - display block - clear both - - &:hover - > footer > button - color var(--noteActionsHighlighted) - - > .avatar - width 60px - height 60px - border-radius 8px - - > header - position absolute - top 28px - left 108px - width calc(100% - 108px) - - > .name - display inline-block - margin 0 - line-height 24px - color var(--noteHeaderName) - font-size 18px - font-weight 700 - text-align left - text-decoration none - - &:hover - text-decoration underline - - > .username - display block - text-align left - margin 0 - color var(--noteHeaderAcct) - - > .info - position absolute - top 0 - right 32px - font-size 1em - - > .time - color var(--noteHeaderInfo) - - > .visibility-info - text-align: right - color var(--noteHeaderInfo) - - > .localOnly - margin-left 4px - - > .body - padding 8px 0 - - > .cw - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - color var(--noteText) - - > .text - margin-right 8px - - > .content - > .text - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - font-size 1.5em - color var(--noteText) - - > .renote - margin 8px 0 - - > * - padding 16px - border dashed 1px var(--quoteBorder) - border-radius 8px - - > .location - margin 4px 0 - font-size 12px - color #ccc - - > .map - width 100% - height 300px - - &:empty - display none - - > .mk-url-preview - margin-top 8px - - > footer - font-size 1.2em - - > .app - display block - font-size 0.8em - margin-left 0.5em - color var(--noteHeaderInfo) - - > button - margin 0 28px 0 0 - padding 8px - background transparent - border none - font-size 1em - color var(--noteActions) - cursor pointer - - &:hover - color var(--noteActionsHover) - - &.replyButton:hover - color var(--noteActionsReplyHover) - - &.renoteButton:hover - color var(--noteActionsRenoteHover) - - &.reactionButton:hover - color var(--noteActionsReactionHover) - - &.inhibitedButton - cursor not-allowed - - > .count - display inline - margin 0 0 0 8px - color var(--text) - opacity 0.7 - - &.reacted, &.reacted:hover - color var(--noteActionsReactionHover) - - > .replies - > * - border-top 1px solid var(--faceDivider) - -</style> diff --git a/src/client/app/desktop/views/components/note-preview.vue b/src/client/app/desktop/views/components/note-preview.vue deleted file mode 100644 index 3b1e71e168..0000000000 --- a/src/client/app/desktop/views/components/note-preview.vue +++ /dev/null @@ -1,88 +0,0 @@ -<template> -<div class="qiziqtywpuaucsgarwajitwaakggnisj" :title="title"> - <mk-avatar class="avatar" :user="note.user" v-if="!narrow"/> - <div class="main"> - <mk-note-header class="header" :note="note" :mini="true"/> - <div class="body"> - <p v-if="note.cw != null" class="cw"> - <mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis" /> - <mk-cw-button v-model="showContent" :note="note"/> - </p> - <div class="content" v-show="note.cw == null || showContent"> - <mk-sub-note-content class="text" :note="note"/> - </div> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: { - note: { - type: Object, - required: true - }, - }, - - inject: { - narrow: { - default: false - } - }, - - data() { - return { - showContent: false - }; - }, - - computed: { - title(): string { - return new Date(this.note.createdAt).toLocaleString(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.qiziqtywpuaucsgarwajitwaakggnisj - display flex - overflow hidden - font-size 0.9em - - > .avatar - flex-shrink 0 - display block - margin 0 12px 0 0 - width 48px - height 48px - border-radius 8px - - > .main - flex 1 - min-width 0 - - > .body - - > .cw - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - color var(--noteText) - - > .text - margin-right 8px - - > .content - > .text - cursor default - margin 0 - padding 0 - color var(--subNoteText) - -</style> diff --git a/src/client/app/desktop/views/components/note.sub.vue b/src/client/app/desktop/views/components/note.sub.vue deleted file mode 100644 index bfecef3eb2..0000000000 --- a/src/client/app/desktop/views/components/note.sub.vue +++ /dev/null @@ -1,106 +0,0 @@ -<template> -<div class="tkfdzaxtkdeianobciwadajxzbddorql" :class="{ mini: narrow }" :title="title"> - <mk-avatar class="avatar" :user="note.user"/> - <div class="main"> - <mk-note-header class="header" :note="note"/> - <div class="body"> - <p v-if="note.cw != null" class="cw"> - <mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis" /> - <mk-cw-button v-model="showContent" :note="note"/> - </p> - <div class="content" v-show="note.cw == null || showContent"> - <mk-sub-note-content class="text" :note="note"/> - </div> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: { - note: { - type: Object, - required: true - } - }, - - inject: { - narrow: { - default: false - } - }, - - data() { - return { - showContent: false - }; - }, - - computed: { - title(): string { - return new Date(this.note.createdAt).toLocaleString(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.tkfdzaxtkdeianobciwadajxzbddorql - display flex - padding 16px 32px - font-size 0.9em - background var(--subNoteBg) - - &.mini - padding 16px - font-size 10px - - > .avatar - margin 0 8px 0 0 - width 38px - height 38px - - > .avatar - flex-shrink 0 - display block - margin 0 12px 0 0 - width 48px - height 48px - border-radius 8px - - > .main - flex 1 - min-width 0 - - > .header - margin-bottom 2px - - > .body - - > .cw - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - color var(--noteText) - - > .text - margin-right 8px - - > .content - > .text - cursor default - margin 0 - padding 0 - color var(--subNoteText) - font-size calc(1em + var(--fontSize)) - - pre - max-height 120px - font-size 80% - -</style> diff --git a/src/client/app/desktop/views/components/note.vue b/src/client/app/desktop/views/components/note.vue deleted file mode 100644 index 1c00faed39..0000000000 --- a/src/client/app/desktop/views/components/note.vue +++ /dev/null @@ -1,323 +0,0 @@ -<template> -<div - class="note" - :class="{ mini: narrow }" - v-show="(this.$store.state.settings.remainDeletedNote || appearNote.deletedAt == null) && !hideThisNote" - :tabindex="appearNote.deletedAt == null ? '-1' : null" - v-hotkey="keymap" - :title="title" -> - <x-sub v-for="note in conversation" :key="note.id" :note="note"/> - <div class="reply-to" v-if="appearNote.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)"> - <x-sub :note="appearNote.reply"/> - </div> - <mk-renote class="renote" v-if="isRenote" :note="note"/> - <article class="article"> - <mk-avatar class="avatar" :user="appearNote.user"/> - <div class="main"> - <mk-note-header class="header" :note="appearNote" :mini="narrow"/> - <div class="body" v-if="appearNote.deletedAt == null"> - <p v-if="appearNote.cw != null" class="cw"> - <mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" /> - <mk-cw-button v-model="showContent" :note="appearNote"/> - </p> - <div class="content" v-show="appearNote.cw == null || showContent"> - <div class="text"> - <span v-if="appearNote.isHidden" style="opacity: 0.5">{{ $t('private') }}</span> - <a class="reply" v-if="appearNote.reply"><fa icon="reply"/></a> - <mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/> - <a class="rp" v-if="appearNote.renote">RN:</a> - </div> - <div class="files" v-if="appearNote.files.length > 0"> - <mk-media-list :media-list="appearNote.files"/> - </div> - <mk-poll v-if="appearNote.poll" :note="appearNote" ref="pollViewer"/> - <a class="location" v-if="appearNote.geo" :href="`https://maps.google.com/maps?q=${appearNote.geo.coordinates[1]},${appearNote.geo.coordinates[0]}`" rel="noopener" target="_blank"><fa icon="map-marker-alt"/> 位置情報</a> - <div class="renote" v-if="appearNote.renote"><mk-note-preview :note="appearNote.renote"/></div> - <mk-url-preview v-for="url in urls" :url="url" :key="url" :compact="compact"/> - </div> - </div> - <footer v-if="appearNote.deletedAt == null" class="footer"> - <span class="app" v-if="appearNote.app && narrow && $store.state.settings.showVia">via <b>{{ appearNote.app.name }}</b></span> - <mk-reactions-viewer :note="appearNote" ref="reactionsViewer"/> - <button class="replyButton button" @click="reply()" :title="$t('reply')"> - <template v-if="appearNote.reply"><fa icon="reply-all"/></template> - <template v-else><fa icon="reply"/></template> - <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> - </button> - <button v-if="['public', 'home'].includes(appearNote.visibility)" class="renoteButton button" @click="renote()" :title="$t('renote')"> - <fa icon="retweet"/> - <p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> - </button> - <button v-else class="inhibitedButton button"> - <fa icon="ban"/> - </button> - <button v-if="!isMyNote && appearNote.myReaction == null" class="reactionButton button" @click="react()" ref="reactButton" :title="$t('add-reaction')"> - <fa icon="plus"/> - <p class="count" v-if="Object.values(appearNote.reactions).some(x => x)">{{ Object.values(appearNote.reactions).reduce((a, c) => a + c, 0) }}</p> - </button> - <button v-if="!isMyNote && appearNote.myReaction != null" class="reactionButton reacted button" @click="undoReact(appearNote)" ref="reactButton" :title="$t('undo-reaction')"> - <fa icon="minus"/> - <p class="count" v-if="Object.values(appearNote.reactions).some(x => x)">{{ Object.values(appearNote.reactions).reduce((a, c) => a + c, 0) }}</p> - </button> - <button @click="menu()" ref="menuButton" class="button"> - <fa icon="ellipsis-h"/> - </button> - </footer> - <div class="deleted" v-if="appearNote.deletedAt != null">{{ $t('deleted') }}</div> - </div> - </article> - <x-sub v-for="note in replies" :key="note.id" :note="note"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -import XSub from './note.sub.vue'; -import noteMixin from '../../../common/scripts/note-mixin'; -import noteSubscriber from '../../../common/scripts/note-subscriber'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/note.vue'), - - components: { - XSub - }, - - mixins: [ - noteMixin(), - noteSubscriber('note') - ], - - props: { - note: { - type: Object, - required: true - }, - detail: { - type: Boolean, - required: false, - default: false - }, - compact: { - type: Boolean, - required: false, - default: false - }, - }, - - inject: { - narrow: { - default: false - } - }, - - data() { - return { - conversation: [], - replies: [] - }; - }, - - created() { - if (this.detail) { - this.$root.api('notes/children', { - noteId: this.appearNote.id, - limit: 30 - }).then(replies => { - this.replies = replies; - }); - - this.$root.api('notes/conversation', { - noteId: this.appearNote.replyId - }).then(conversation => { - this.conversation = conversation.reverse(); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.note - margin 0 - padding 0 - overflow hidden - background var(--face) - border-bottom solid var(--lineWidth) var(--faceDivider) - - &.mini - font-size 13px - - > .renote - padding 8px 16px 0 16px - - .avatar - width 20px - height 20px - - > .article - padding 16px 16px 4px - - > .avatar - margin 0 10px 8px 0 - width 42px - height 42px - - &:last-of-type - border-bottom none - - &:focus - z-index 1 - - &:after - content "" - pointer-events none - position absolute - top 2px - right 2px - bottom 2px - left 2px - border 2px solid var(--primaryAlpha03) - border-radius 4px - - > .renote + article - padding-top 8px - - > .article - display flex - padding 28px 32px 18px 32px - - &:hover - > .main > footer > button - color var(--noteActionsHighlighted) - - > .avatar - flex-shrink 0 - display block - margin 0 16px 10px 0 - width 58px - height 58px - border-radius 8px - //position -webkit-sticky - //position sticky - //top 74px - - > .main - flex 1 - min-width 0 - - > .header - margin-bottom 4px - - > .body - - > .cw - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - color var(--noteText) - - > .text - margin-right 8px - - > .content - - > .text - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - color var(--noteText) - font-size calc(1em + var(--fontSize)) - - > .reply - margin-right 8px - color var(--text) - - > .rp - margin-left 4px - font-style oblique - color var(--renoteText) - - > .location - margin 4px 0 - font-size 12px - color #ccc - - > .map - width 100% - height 300px - - &:empty - display none - - .mk-url-preview - margin-top 8px - - > .mk-poll - font-size 80% - - > .renote - margin 8px 0 - - > * - padding 16px - border dashed var(--lineWidth) var(--quoteBorder) - border-radius 8px - - > .footer - > .app - display block - margin-top 0.5em - margin-left 0.5em - color var(--noteHeaderInfo) - font-size 0.8em - - > .button - margin 0 28px 0 0 - padding 0 8px - line-height 32px - font-size 1em - color var(--noteActions) - background transparent - border none - cursor pointer - - &:last-child - margin-right 0 - - &:hover - color var(--noteActionsHover) - - &.replyButton:hover - color var(--noteActionsReplyHover) - - &.renoteButton:hover - color var(--noteActionsRenoteHover) - - &.reactionButton:hover - color var(--noteActionsReactionHover) - - &.inhibitedButton - cursor not-allowed - - > .count - display inline - margin 0 0 0 8px - color var(--text) - opacity 0.7 - - &.reacted, &.reacted:hover - color var(--noteActionsReactionHover) - - > .deleted - color var(--noteText) - opacity 0.7 - -</style> diff --git a/src/client/app/desktop/views/components/notes.vue b/src/client/app/desktop/views/components/notes.vue deleted file mode 100644 index 0820d5d80c..0000000000 --- a/src/client/app/desktop/views/components/notes.vue +++ /dev/null @@ -1,182 +0,0 @@ -<template> -<div class="mk-notes" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> - <slot name="header"></slot> - - <div class="newer-indicator" :style="{ top: $store.state.uiHeaderHeight + 'px' }" v-show="queue.length > 0"></div> - - <div class="empty" v-if="empty">{{ $t('@.no-notes') }}</div> - - <mk-error v-if="error" @retry="init()"/> - - <div class="placeholder" v-if="fetching"> - <template v-for="i in 10"> - <mk-note-skeleton :key="i"/> - </template> - </div> - - <!-- トランジションを有効にするとなぜかメモリリークする --> - <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="notes transition" tag="div" ref="notes"> - <template v-for="(note, i) in _notes"> - <mk-note :note="note" :key="note.id" :compact="true" ref="note"/> - <p class="date" :key="note.id + '_date'" v-if="i != items.length - 1 && note._date != _notes[i + 1]._date"> - <span><fa icon="angle-up"/>{{ note._datetext }}</span> - <span><fa icon="angle-down"/>{{ _notes[i + 1]._datetext }}</span> - </p> - </template> - </component> - - <footer v-if="more"> - <button @click="fetchMore()" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> - <template v-if="!moreFetching">{{ $t('@.load-more') }}</template> - <template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template> - </button> - </footer> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import * as config from '../../../config'; -import shouldMuteNote from '../../../common/scripts/should-mute-note'; -import paging from '../../../common/scripts/paging'; - -export default Vue.extend({ - i18n: i18n(), - - mixins: [ - paging({ - captureWindowScroll: true, - - onQueueChanged: (self, x) => { - if (x.length > 0) { - self.$store.commit('indicate', true); - } else { - self.$store.commit('indicate', false); - } - }, - - onPrepend: (self, note, silent) => { - // 弾く - if (shouldMuteNote(self.$store.state.i, self.$store.state.settings, note)) return false; - - // タブが非表示またはスクロール位置が最上部ではないならタイトルで通知 - if (document.hidden || !self.isScrollTop()) { - self.$store.commit('pushBehindNote', note); - } - - if (self.isScrollTop()) { - // サウンドを再生する - if (self.$store.state.device.enableSounds && !silent) { - const sound = new Audio(`${config.url}/assets/post.mp3`); - sound.volume = self.$store.state.device.soundVolume; - sound.play(); - } - } - }, - - onInited: (self) => { - self.$emit('loaded'); - } - }), - ], - - props: { - pagination: { - required: true - }, - }, - - computed: { - _notes(): any[] { - return (this.items as any).map(item => { - const date = new Date(item.createdAt).getDate(); - const month = new Date(item.createdAt).getMonth() + 1; - item._date = date; - item._datetext = this.$t('@.month-and-day').replace('{month}', month.toString()).replace('{day}', date.toString()); - return item; - }); - } - }, - - methods: { - focus() { - (this.$refs.notes as any).children[0].focus ? (this.$refs.notes as any).children[0].focus() : (this.$refs.notes as any).$el.children[0].focus(); - }, - } -}); -</script> - -<style lang="stylus" scoped> -.mk-notes - background var(--face) - overflow hidden - - &.round - border-radius 6px - - &.shadow - box-shadow 0 3px 8px rgba(0, 0, 0, 0.2) - - .transition - .mk-notes-enter - .mk-notes-leave-to - opacity 0 - transform translateY(-30px) - - > * - transition transform .3s ease, opacity .3s ease - - > .empty - padding 16px - text-align center - color var(--text) - - > .placeholder - padding 32px - opacity 0.3 - - > .notes - > .date - display block - margin 0 - line-height 32px - font-size 14px - text-align center - color var(--dateDividerFg) - background var(--dateDividerBg) - border-bottom solid var(--lineWidth) var(--faceDivider) - - span - margin 0 16px - - [data-icon] - margin-right 8px - - > .newer-indicator - position -webkit-sticky - position sticky - z-index 100 - height 3px - background var(--primary) - - > footer - > button - display block - margin 0 - padding 16px - width 100% - text-align center - color #ccc - background var(--face) - border-top solid var(--lineWidth) var(--faceDivider) - border-bottom-left-radius 6px - border-bottom-right-radius 6px - - &:hover - box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05) - - &:active - box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1) - -</style> diff --git a/src/client/app/desktop/views/components/notifications.vue b/src/client/app/desktop/views/components/notifications.vue deleted file mode 100644 index a2504abe66..0000000000 --- a/src/client/app/desktop/views/components/notifications.vue +++ /dev/null @@ -1,379 +0,0 @@ -<template> -<div class="mk-notifications"> - <div class="placeholder" v-if="fetching"> - <template v-for="i in 10"> - <mk-note-skeleton :key="i"/> - </template> - </div> - - <div class="notifications" v-if="!empty"> - <!-- トランジションを有効にするとなぜかメモリリークする --> - <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition" tag="div"> - <template v-for="(notification, i) in _notifications"> - <div class="notification" :class="notification.type" :key="notification.id"> - <template v-if="notification.type == 'reaction'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div class="text"> - <header> - <mk-reaction-icon :reaction="notification.reaction" class="icon"/> - <router-link :to="notification.user | userPage" v-user-preview="notification.user.id" class="name"> - <mk-user-name :user="notification.user"/> - </router-link> - <mk-time :time="notification.createdAt"/> - </header> - <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> - <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :custom-emojis="notification.note.emojis"/> - <fa icon="quote-right"/> - </router-link> - </div> - </template> - - <template v-if="notification.type == 'renote'"> - <mk-avatar class="avatar" :user="notification.note.user"/> - <div class="text"> - <header> - <fa icon="retweet" class="icon"/> - <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId" class="name"> - <mk-user-name :user="notification.note.user"/> - </router-link> - <mk-time :time="notification.createdAt"/> - </header> - <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note.renote)"> - <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="true" :custom-emojis="notification.note.renote.emojis"/> - <fa icon="quote-right"/> - </router-link> - </div> - </template> - - <template v-if="notification.type == 'quote'"> - <mk-avatar class="avatar" :user="notification.note.user"/> - <div class="text"> - <header> - <fa icon="quote-left" class="icon"/> - <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId" class="name"> - <mk-user-name :user="notification.note.user"/> - </router-link> - <mk-time :time="notification.createdAt"/> - </header> - <router-link class="note-preview" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> - <mfm :text="getNoteSummary(notification.note)" :plain="true" :custom-emojis="notification.note.emojis"/> - </router-link> - </div> - </template> - - <template v-if="notification.type == 'follow'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div class="text"> - <header> - <fa icon="user-plus" class="icon"/> - <router-link :to="notification.user | userPage" v-user-preview="notification.user.id" class="name"> - <mk-user-name :user="notification.user"/> - </router-link> - <mk-time :time="notification.createdAt"/> - </header> - </div> - </template> - - <template v-if="notification.type == 'receiveFollowRequest'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div class="text"> - <header> - <fa icon="user-clock" class="icon"/> - <router-link :to="notification.user | userPage" v-user-preview="notification.user.id" class="name"> - <mk-user-name :user="notification.user"/> - </router-link> - <mk-time :time="notification.createdAt"/> - </header> - </div> - </template> - - <template v-if="notification.type == 'reply'"> - <mk-avatar class="avatar" :user="notification.note.user"/> - <div class="text"> - <header> - <fa icon="reply" class="icon"/> - <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId" class="name"> - <mk-user-name :user="notification.note.user"/> - </router-link> - <mk-time :time="notification.createdAt"/> - </header> - <router-link class="note-preview" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> - <mfm :text="getNoteSummary(notification.note)" :plain="true" :custom-emojis="notification.note.emojis"/> - </router-link> - </div> - </template> - - <template v-if="notification.type == 'mention'"> - <mk-avatar class="avatar" :user="notification.note.user"/> - <div class="text"> - <header> - <fa icon="at" class="icon"/> - <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId" class="name"> - <mk-user-name :user="notification.note.user"/> - </router-link> - <mk-time :time="notification.createdAt"/> - </header> - <router-link class="note-preview" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> - <mfm :text="getNoteSummary(notification.note)" :plain="true" :custom-emojis="notification.note.emojis"/> - </router-link> - </div> - </template> - - <template v-if="notification.type == 'pollVote'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div class="text"> - <header> - <fa icon="chart-pie" class="icon"/> - <router-link :to="notification.user | userPage" v-user-preview="notification.user.id" class="name"> - <mk-user-name :user="notification.user"/> - </router-link> - <mk-time :time="notification.createdAt"/> - </header> - <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> - <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :custom-emojis="notification.note.emojis"/> - <fa icon="quote-right"/> - </router-link> - </div> - </template> - </div> - - <p class="date" v-if="i != items.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'"> - <span><fa icon="angle-up"/>{{ notification._datetext }}</span> - <span><fa icon="angle-down"/>{{ _notifications[i + 1]._datetext }}</span> - </p> - </template> - </component> - </div> - <button class="more" :class="{ fetching: moreFetching }" v-if="more" @click="fetchMore" :disabled="moreFetching"> - <template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template>{{ moreFetching ? $t('@.loading') : $t('@.load-more') }} - </button> - <p class="empty" v-if="empty">{{ $t('empty') }}</p> - <mk-error v-if="error" @retry="init()"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import getNoteSummary from '../../../../../misc/get-note-summary'; -import paging from '../../../common/scripts/paging'; - -export default Vue.extend({ - i18n: i18n(), - - mixins: [ - paging({ - isContainer: true - }), - ], - - props: { - type: { - type: String, - required: false - } - }, - - data() { - return { - connection: null, - getNoteSummary, - pagination: { - endpoint: 'i/notifications', - limit: 10, - params: () => ({ - includeTypes: this.type ? [this.type] : undefined - }) - } - }; - }, - - computed: { - _notifications(): any[] { - return (this.items as any).map(notification => { - const date = new Date(notification.createdAt).getDate(); - const month = new Date(notification.createdAt).getMonth() + 1; - notification._date = date; - notification._datetext = this.$t('@.month-and-day').replace('{month}', month.toString()).replace('{day}', date.toString()); - return notification; - }); - } - }, - - watch: { - type() { - this.reload(); - } - }, - - mounted() { - this.connection = this.$root.stream.useSharedConnection('main'); - this.connection.on('notification', this.onNotification); - }, - - beforeDestroy() { - this.connection.dispose(); - }, - - methods: { - onNotification(notification) { - // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない - this.$root.stream.send('readNotification', { - id: notification.id - }); - - this.prepend(notification); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-notifications - .transition - .mk-notifications-enter - .mk-notifications-leave-to - opacity 0 - transform translateY(-30px) - - > * - transition transform .3s ease, opacity .3s ease - - > .placeholder - padding 16px - opacity 0.3 - - > .notifications - > div - > .notification - margin 0 - padding 16px - overflow-wrap break-word - font-size 12px - border-bottom solid var(--lineWidth) var(--faceDivider) - - &:last-child - border-bottom none - - &:after - content "" - display block - clear both - - > .avatar - display block - float left - position -webkit-sticky - position sticky - top 16px - width 36px - height 36px - border-radius 6px - - > .text - float right - width calc(100% - 36px) - padding-left 8px - - > header - display flex - align-items baseline - white-space nowrap - - > .icon - margin-right 4px - - > .name - overflow hidden - text-overflow ellipsis - - > .mk-time - margin-left auto - color var(--noteHeaderInfo) - font-size 0.9em - - .note-preview - color var(--noteText) - display inline-block - word-break break-word - - .note-ref - color var(--noteText) - display inline-block - width: 100% - overflow hidden - white-space nowrap - text-overflow ellipsis - - [data-icon] - font-size 1em - font-weight normal - font-style normal - display inline-block - margin-right 3px - - &.reaction - .text header - align-items normal - - &.renote, &.quote - .text header [data-icon] - color #77B255 - - &.follow - .text header [data-icon] - color #53c7ce - - &.receiveFollowRequest - .text header [data-icon] - color #888 - - &.reply, &.mention - .text header [data-icon] - color #555 - - > .date - display block - margin 0 - line-height 32px - text-align center - font-size 0.8em - color var(--dateDividerFg) - background var(--dateDividerBg) - border-bottom solid var(--lineWidth) var(--faceDivider) - - span - margin 0 16px - - [data-icon] - margin-right 8px - - > .more - display block - width 100% - padding 16px - color var(--text) - border-top solid var(--lineWidth) rgba(#000, 0.05) - - &:hover - background rgba(#000, 0.025) - - &:active - background rgba(#000, 0.05) - - &.fetching - cursor wait - - > [data-icon] - margin-right 4px - - > .empty - margin 0 - padding 16px - text-align center - color var(--text) - -</style> diff --git a/src/client/app/desktop/views/components/post-form-window.vue b/src/client/app/desktop/views/components/post-form-window.vue deleted file mode 100644 index ff6f24b6e1..0000000000 --- a/src/client/app/desktop/views/components/post-form-window.vue +++ /dev/null @@ -1,140 +0,0 @@ -<template> -<mk-window class="mk-post-form-window" ref="window" is-modal @closed="onWindowClosed" :animation="animation"> - <template #header> - <span class="mk-post-form-window--header"> - <span class="icon" v-if="geo"><fa icon="map-marker-alt"/></span> - <span v-if="!reply">{{ $t('note') }}</span> - <span v-if="reply">{{ $t('reply') }}</span> - <span class="count" v-if="files.length != 0">{{ $t('attaches').replace('{}', files.length) }}</span> - <span class="count" v-if="uploadings.length != 0">{{ $t('uploading-media').replace('{}', uploadings.length) }}<mk-ellipsis/></span> - </span> - </template> - - <div class="mk-post-form-window--body" :style="{ maxHeight: `${maxHeight}px` }"> - <mk-note-preview v-if="reply" class="notePreview" :note="reply"/> - <x-post-form ref="form" - :reply="reply" - :mention="mention" - :initial-text="initialText" - :initial-note="initialNote" - :instant="instant" - - @posted="onPosted" - @change-uploadings="onChangeUploadings" - @change-attached-files="onChangeFiles" - @geo-attached="onGeoAttached" - @geo-dettached="onGeoDettached"/> - </div> -</mk-window> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XPostForm from './post-form.vue'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/post-form-window.vue'), - - components: { - XPostForm - }, - - props: { - reply: { - type: Object, - required: false - }, - mention: { - type: Object, - required: false - }, - - animation: { - type: Boolean, - required: false, - default: true - }, - - initialText: { - type: String, - required: false - }, - - initialNote: { - type: Object, - required: false - }, - - instant: { - type: Boolean, - required: false, - default: false - }, - }, - - data() { - return { - uploadings: [], - files: [], - geo: null - }; - }, - - computed: { - maxHeight() { - return window.innerHeight - 50; - }, - }, - - mounted() { - this.$nextTick(() => { - (this.$refs.form as any).focus(); - }); - }, - - methods: { - onChangeUploadings(files) { - this.uploadings = files; - }, - onChangeFiles(files) { - this.files = files; - }, - onGeoAttached(geo) { - this.geo = geo; - }, - onGeoDettached() { - this.geo = null; - }, - onPosted() { - (this.$refs.window as any).close(); - }, - onWindowClosed() { - this.$emit('closed'); - this.destroyDom(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-post-form-window - .mk-post-form-window--header - .icon - margin-right 8px - - .count - margin-left 8px - opacity 0.8 - - &:before - content '(' - - &:after - content ')' - - .mk-post-form-window--body - .notePreview - margin 16px 22px - -</style> diff --git a/src/client/app/desktop/views/components/post-form.vue b/src/client/app/desktop/views/components/post-form.vue deleted file mode 100644 index b9c0624bd7..0000000000 --- a/src/client/app/desktop/views/components/post-form.vue +++ /dev/null @@ -1,331 +0,0 @@ -<template> -<div class="gjisdzwh" - @dragover.stop="onDragover" - @dragenter="onDragenter" - @dragleave="onDragleave" - @drop.stop="onDrop" -> - <div class="content"> - <div class="hashtags" v-if="recentHashtags.length > 0 && $store.state.settings.suggestRecentHashtags"> - <b>{{ $t('@.post-form.recent-tags') }}:</b> - <a v-for="tag in recentHashtags.slice(0, 5)" @click="addTag(tag)" :title="$t('@.post-form.click-to-tagging')">#{{ tag }}</a> - </div> - <div class="with-quote" v-if="quoteId"><fa icon="quote-left"/> {{ $t('@.post-form.quote-attached') }}<button @click="quoteId = null"><fa icon="times"/></button></div> - <div v-if="visibility === 'specified'" class="to-specified"> - <fa icon="envelope"/> {{ $t('@.post-form.specified-recipient') }} - <div class="visibleUsers"> - <span v-for="u in visibleUsers"> - <mk-user-name :user="u"/> - <button @click="removeVisibleUser(u)"><fa icon="times"/></button> - </span> - <button @click="addVisibleUser">{{ $t('@.post-form.add-visible-user') }}</button> - </div> - </div> - <div class="local-only" v-if="localOnly === true"><fa icon="heart"/> {{ $t('@.post-form.local-only-message') }}</div> - <input v-show="useCw" ref="cw" v-model="cw" :placeholder="$t('@.post-form.cw-placeholder')" v-autocomplete="{ model: 'cw' }"> - <div class="textarea"> - <textarea :class="{ with: (files.length != 0 || poll) }" - ref="text" v-model="text" :disabled="posting" - @keydown="onKeydown" @paste="onPaste" :placeholder="placeholder" - v-autocomplete="{ model: 'text' }" - ></textarea> - <button class="emoji" @click="emoji" ref="emoji"> - <fa :icon="['far', 'laugh']"/> - </button> - <x-post-form-attaches class="files" :class="{ with: poll }" :files="files"/> - <x-poll-editor class="poll-editor" v-if="poll" ref="poll" @destroyed="poll = false" @updated="onPollUpdate()"/> - </div> - </div> - <mk-uploader ref="uploader" @uploaded="attachMedia" @change="onChangeUploadings"/> - <button class="upload" :title="$t('@.post-form.attach-media-from-local')" @click="chooseFile"><fa icon="upload"/></button> - <button class="drive" :title="$t('@.post-form.attach-media-from-drive')" @click="chooseFileFromDrive"><fa icon="cloud"/></button> - <button class="kao" :title="$t('@.post-form.insert-a-kao')" @click="kao"><fa :icon="['far', 'smile']"/></button> - <button class="poll" :title="$t('@.post-form.create-poll')" @click="poll = !poll"><fa icon="chart-pie"/></button> - <button class="cw" :title="$t('@.post-form.hide-contents')" @click="useCw = !useCw"><fa :icon="['far', 'eye-slash']"/></button> - <button class="geo" :title="$t('@.post-form.attach-location-information')" @click="geo ? removeGeo() : setGeo()"><fa icon="map-marker-alt"/></button> - <button class="visibility" :title="$t('@.post-form.visibility')" @click="setVisibility" ref="visibilityButton"> - <span v-if="visibility === 'public'"><fa icon="globe"/></span> - <span v-if="visibility === 'home'"><fa icon="home"/></span> - <span v-if="visibility === 'followers'"><fa icon="unlock"/></span> - <span v-if="visibility === 'specified'"><fa icon="envelope"/></span> - </button> - <p class="text-count" :class="{ over: trimmedLength(text) > maxNoteTextLength }">{{ maxNoteTextLength - trimmedLength(text) }}</p> - <ui-button primary :wait="posting" class="submit" :disabled="!canPost" @click="post"> - {{ posting ? $t('@.post-form.posting') : submitText }}<mk-ellipsis v-if="posting"/> - </ui-button> - <input ref="file" type="file" multiple="multiple" tabindex="-1" @change="onChangeFile"/> - <div class="dropzone" v-if="draghover"></div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import form from '../../../common/scripts/post-form'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/post-form.vue'), - - mixins: [ - form({ - onSuccess: self => { - self.$notify(self.renote - ? self.$t('reposted') - : self.reply - ? self.$t('replied') - : self.$t('posted')); - }, - onFailure: self => { - self.$notify(self.renote - ? self.$t('renote-failed') - : self.reply - ? self.$t('reply-failed') - : self.$t('note-failed')); - } - }), - ], -}); -</script> - -<style lang="stylus" scoped> -.gjisdzwh - display block - padding 16px - background var(--desktopPostFormBg) - overflow hidden - - &:after - content "" - display block - clear both - - > .content - > input - > .textarea > textarea - display block - width 100% - padding 12px - font-size 16px - color var(--desktopPostFormTextareaFg) - background var(--desktopPostFormTextareaBg) - outline none - border solid 1px var(--primaryAlpha01) - border-radius 4px - transition border-color .2s ease - padding-right 30px - - &:hover - border-color var(--primaryAlpha02) - transition border-color .1s ease - - &:focus - border-color var(--primaryAlpha05) - transition border-color 0s ease - - &:disabled - opacity 0.5 - - &::-webkit-input-placeholder - color var(--primaryAlpha03) - - > input - margin-bottom 8px - - > .textarea - > .emoji - position absolute - top 0 - right 0 - padding 10px - font-size 18px - color var(--text) - opacity 0.5 - - &:hover - color var(--textHighlighted) - opacity 1 - - &:active - color var(--primary) - opacity 1 - - > textarea - margin 0 - max-width 100% - min-width 100% - min-height 84px - - &:hover - & + * + * - & + * + * + * - border-color var(--primaryAlpha02) - transition border-color .1s ease - - &:focus - & + * + * - & + * + * + * - border-color var(--primaryAlpha05) - transition border-color 0s ease - - & + .emoji - opacity 0.7 - - &.with - border-bottom solid 1px var(--primaryAlpha01) !important - border-radius 4px 4px 0 0 - - > .files - margin 0 - padding 0 - background var(--desktopPostFormTextareaBg) - border solid 1px var(--primaryAlpha01) - border-top none - border-radius 0 0 4px 4px - transition border-color .3s ease - - &.with - border-bottom solid 1px var(--primaryAlpha01) !important - border-radius 0 - - > .poll-editor - background var(--desktopPostFormTextareaBg) - border solid 1px var(--primaryAlpha01) - border-top none - border-radius 0 0 4px 4px - transition border-color .3s ease - - > .hashtags - margin 0 0 8px 0 - overflow hidden - white-space nowrap - font-size 14px - - > b - color var(--primary) - - > * - margin-right 8px - white-space nowrap - - > .with-quote - margin 0 0 8px 0 - color var(--primary) - - > button - padding 4px 8px - color var(--primaryAlpha04) - - &:hover - color var(--primaryAlpha06) - - &:active - color var(--primaryDarken30) - - > .to-specified - margin 0 0 8px 0 - color var(--primary) - - > .visibleUsers - display inline - top -1px - font-size 14px - - > span - margin-left 14px - - > button - padding 4px 8px - color var(--primaryAlpha04) - - &:hover - color var(--primaryAlpha06) - - &:active - color var(--primaryDarken30) - - > .local-only - margin 0 0 8px 0 - color var(--primary) - - > .mk-uploader - margin 8px 0 0 0 - padding 8px - border solid 1px var(--primaryAlpha02) - border-radius 4px - - input[type='file'] - display none - - .submit - display block - position absolute - bottom 16px - right 16px - width 110px - height 40px - - > .text-count - pointer-events none - display block - position absolute - bottom 16px - right 138px - margin 0 - line-height 40px - color var(--primaryAlpha05) - - &.over - color #ec3828 - - > .upload - > .drive - > .kao - > .poll - > .cw - > .geo - > .visibility - display inline-block - cursor pointer - padding 0 - margin 8px 4px 0 0 - width 40px - height 40px - font-size 1em - color var(--desktopPostFormTransparentButtonFg) - background transparent - outline none - border solid 1px transparent - border-radius 4px - - &:hover - background transparent - border-color var(--primaryAlpha03) - - &:active - color var(--primaryAlpha06) - background linear-gradient(to bottom, var(--desktopPostFormTransparentButtonActiveGradientStart) 0%, var(--desktopPostFormTransparentButtonActiveGradientEnd) 100%) - border-color var(--primaryAlpha05) - box-shadow 0 2px 4px rgba(#000, 0.15) inset - - &:focus - &:after - content "" - pointer-events none - position absolute - top -5px - right -5px - bottom -5px - left -5px - border 2px solid var(--primaryAlpha03) - border-radius 8px - - > .dropzone - position absolute - left 0 - top 0 - width 100% - height 100% - border dashed 2px var(--primaryAlpha05) - pointer-events none - -</style> diff --git a/src/client/app/desktop/views/components/progress-dialog.vue b/src/client/app/desktop/views/components/progress-dialog.vue deleted file mode 100644 index 28b35dbd97..0000000000 --- a/src/client/app/desktop/views/components/progress-dialog.vue +++ /dev/null @@ -1,98 +0,0 @@ -<template> -<mk-window ref="window" :is-modal="false" :can-close="false" width="500px" @closed="destroyDom"> - <template #header>{{ title }}<mk-ellipsis/></template> - <div :class="$style.body"> - <p :class="$style.init" v-if="isNaN(value)">{{ $t('waiting') }}<mk-ellipsis/></p> - <p :class="$style.percentage" v-if="!isNaN(value)">{{ Math.floor((value / max) * 100) }}</p> - <progress :class="$style.progress" - v-if="!isNaN(value) && value < max" - :value="isNaN(value) ? 0 : value" - :max="max" - ></progress> - <div :class="[$style.progress, $style.waiting]" v-if="value >= max"></div> - </div> -</mk-window> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/progress-dialog.vue'), - props: ['title', 'initValue', 'initMax'], - data() { - return { - value: this.initValue, - max: this.initMax - }; - }, - methods: { - update(value, max) { - this.value = parseInt(value, 10); - this.max = parseInt(max, 10); - }, - close() { - (this.$refs.window as any).close(); - } - } -}); -</script> - -<style lang="stylus" module> - - -.body - padding 18px 24px 24px 24px - -.init - display block - margin 0 - text-align center - color rgba(#000, 0.7) - -.percentage - display block - margin 0 0 4px 0 - text-align center - line-height 16px - color var(--primaryAlpha07) - - &:after - content '%' - -.progress - display block - margin 0 - width 100% - height 10px - background transparent - border none - border-radius 4px - overflow hidden - - &::-webkit-progress-value - background var(--primary) - - &::-webkit-progress-bar - background var(--primaryAlpha01) - -.waiting - background linear-gradient( - 45deg, - var(--primaryLighten30) 25%, - var(--primary) 25%, - var(--primary) 50%, - var(--primaryLighten30) 50%, - var(--primaryLighten30) 75%, - var(--primary) 75%, - var(--primary) - ) - background-size 32px 32px - animation progress-dialog-tag-progress-waiting 1.5s linear infinite - - @keyframes progress-dialog-tag-progress-waiting - from {background-position: 0 0;} - to {background-position: -64px 32px;} - -</style> diff --git a/src/client/app/desktop/views/components/renote-form-window.vue b/src/client/app/desktop/views/components/renote-form-window.vue deleted file mode 100644 index 0ca347b530..0000000000 --- a/src/client/app/desktop/views/components/renote-form-window.vue +++ /dev/null @@ -1,66 +0,0 @@ -<template> -<mk-window ref="window" is-modal @closed="onWindowClosed" :animation="animation"> - <template #header :class="$style.header"><fa icon="retweet"/>{{ $t('title') }}</template> - <mk-renote-form ref="form" :note="note" @posted="onPosted" @canceled="onCanceled" v-hotkey.global="keymap"/> -</mk-window> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/renote-form-window.vue'), - props: { - note: { - type: Object, - required: true - }, - - animation: { - type: Boolean, - required: false, - default: true - } - }, - - computed: { - keymap(): any { - return { - 'esc': this.close, - 'enter': this.post, - 'q': this.quote, - }; - } - }, - - methods: { - post() { - (this.$refs.form as any).ok(); - }, - quote() { - (this.$refs.form as any).onQuote(); - }, - close() { - (this.$refs.window as any).close(); - }, - onPosted() { - (this.$refs.window as any).close(); - }, - onCanceled() { - (this.$refs.window as any).close(); - }, - onWindowClosed() { - this.$emit('closed'); - this.destroyDom(); - } - } -}); -</script> - -<style lang="stylus" module> -.header - > [data-icon] - margin-right 4px - -</style> diff --git a/src/client/app/desktop/views/components/renote-form.vue b/src/client/app/desktop/views/components/renote-form.vue deleted file mode 100644 index 53fbf0ff30..0000000000 --- a/src/client/app/desktop/views/components/renote-form.vue +++ /dev/null @@ -1,111 +0,0 @@ -<template> -<div class="mk-renote-form"> - <mk-note-preview class="preview" :note="note"/> - <template v-if="!quote"> - <footer> - <a class="quote" v-if="!quote" @click="onQuote">{{ $t('quote') }}</a> - <ui-button class="button cancel" inline @click="cancel">{{ $t('cancel') }}</ui-button> - <ui-button class="button home" inline :primary="visibility != 'public'" @click="ok('home')" :disabled="wait">{{ wait ? this.$t('reposting') : this.$t('renote-home') }}</ui-button> - <ui-button class="button ok" inline :primary="visibility == 'public'" @click="ok('public')" :disabled="wait">{{ wait ? this.$t('reposting') : this.$t('renote') }}</ui-button> - </footer> - </template> - <template v-if="quote"> - <x-post-form ref="form" :renote="note" @posted="onChildFormPosted"/> - </template> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/renote-form.vue'), - - components: { - XPostForm: () => import('./post-form.vue').then(m => m.default) - }, - - props: { - note: { - type: Object, - required: true - } - }, - - data() { - return { - wait: false, - quote: false, - visibility: this.$store.state.settings.defaultNoteVisibility - }; - }, - - methods: { - ok(v: string) { - this.wait = true; - this.$root.api('notes/create', { - renoteId: this.note.id, - visibility: v || this.visibility - }).then(data => { - this.$emit('posted'); - this.$notify(this.$t('success')); - }).catch(err => { - this.$notify(this.$t('failure')); - }).then(() => { - this.wait = false; - }); - }, - - cancel() { - this.$emit('canceled'); - }, - - onQuote() { - this.quote = true; - - this.$nextTick(() => { - (this.$refs.form as any).focus(); - }); - }, - - onChildFormPosted() { - this.$emit('posted'); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-renote-form - > .preview - margin 16px 22px - - > footer - height 72px - background var(--desktopRenoteFormFooter) - - > .quote - position absolute - bottom 16px - left 28px - line-height 40px - - > .button - display block - position absolute - bottom 16px - width 120px - height 40px - - &.cancel - right 280px - - &.home - right 148px - font-size 13px - - &.ok - right 16px - -</style> diff --git a/src/client/app/desktop/views/components/settings-window.vue b/src/client/app/desktop/views/components/settings-window.vue deleted file mode 100644 index 9bfd5a14c7..0000000000 --- a/src/client/app/desktop/views/components/settings-window.vue +++ /dev/null @@ -1,38 +0,0 @@ -<template> -<mk-window ref="window" is-modal width="700px" height="550px" @closed="destroyDom"> - <template #header :class="$style.header"><fa icon="cog"/>{{ $t('@.settings') }}</template> - <x-settings :initial-page="initialPage" @done="close"/> -</mk-window> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/settings-window.vue'), - - components: { - XSettings: () => import('./settings.vue').then(m => m.default) - }, - - props: { - initialPage: { - type: String, - required: false - } - }, - methods: { - close() { - (this as any).$refs.window.close(); - } - } -}); -</script> - -<style lang="stylus" module> -.header - > [data-icon] - margin-right 4px - -</style> diff --git a/src/client/app/desktop/views/components/settings.vue b/src/client/app/desktop/views/components/settings.vue deleted file mode 100644 index 65701cd5f3..0000000000 --- a/src/client/app/desktop/views/components/settings.vue +++ /dev/null @@ -1,86 +0,0 @@ -<template> -<div class="mk-settings"> - <div class="nav" :class="{ inWindow }"> - <router-link to="/i/settings/profile" active-class="active"><fa icon="user" fixed-width/>{{ $t('@._settings.profile') }}</router-link> - <router-link to="/i/settings/appearance" active-class="active"><fa icon="palette" fixed-width/>{{ $t('@._settings.appearance') }}</router-link> - <router-link to="/i/settings/behavior" active-class="active"><fa icon="desktop" fixed-width/>{{ $t('@._settings.behavior') }}</router-link> - <router-link to="/i/settings/notification" active-class="active"><fa :icon="['far', 'bell']" fixed-width/>{{ $t('@._settings.notification') }}</router-link> - <router-link to="/i/settings/drive" active-class="active"><fa icon="cloud" fixed-width/>{{ $t('@.drive') }}</router-link> - <router-link to="/i/settings/hashtags" active-class="active"><fa icon="hashtag" fixed-width/>{{ $t('@._settings.tags') }}</router-link> - <router-link to="/i/settings/muteAndBlock" active-class="active"><fa icon="ban" fixed-width/>{{ $t('@._settings.mute-and-block') }}</router-link> - <router-link to="/i/settings/apps" active-class="active"><fa icon="puzzle-piece" fixed-width/>{{ $t('@._settings.apps') }}</router-link> - <router-link to="/i/settings/security" active-class="active"><fa icon="unlock-alt" fixed-width/>{{ $t('@._settings.security') }}</router-link> - <router-link to="/i/settings/api" active-class="active"><fa icon="key" fixed-width/>API</router-link> - <router-link to="/i/settings/other" active-class="active"><fa icon="cogs" fixed-width/>{{ $t('@._settings.other') }}</router-link> - </div> - <div class="pages"> - <x-settings :page="page"/> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XSettings from '../../../common/views/components/settings/settings.vue'; - -export default Vue.extend({ - i18n: i18n(), - components: { - XSettings, - }, - props: { - page: { - type: String, - required: true, - }, - inWindow: { - type: Boolean, - required: false, - default: true - } - }, -}); -</script> - -<style lang="stylus" scoped> -.mk-settings - display flex - width 100% - height 100% - - > .nav - flex 0 0 200px - width 100% - height 100% - padding 16px 0 0 0 - overflow auto - z-index 1 - font-size 15px - - > a - display block - padding 10px 16px - margin 0 - color var(--desktopSettingsNavItem) - cursor pointer - user-select none - transition margin-left 0.2s ease - - > [data-icon] - margin-right 4px - - &:hover - color var(--desktopSettingsNavItemHover) - - &.active - margin-left 8px - color var(--primary) !important - - > .pages - width 100% - height 100% - flex auto - overflow auto - -</style> diff --git a/src/client/app/desktop/views/components/sub-note-content.vue b/src/client/app/desktop/views/components/sub-note-content.vue deleted file mode 100644 index 78f9a6034b..0000000000 --- a/src/client/app/desktop/views/components/sub-note-content.vue +++ /dev/null @@ -1,48 +0,0 @@ -<template> -<div class="mk-sub-note-content"> - <div class="body"> - <span v-if="note.isHidden" style="opacity: 0.5">{{ $t('private') }}</span> - <span v-if="note.deletedAt" style="opacity: 0.5">{{ $t('deleted') }}</span> - <a class="reply" v-if="note.replyId"><fa icon="reply"/></a> - <mfm v-if="note.text" :text="note.text" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis"/> - <a class="rp" v-if="note.renoteId" :href="`/notes/${note.renoteId}`">RN: ...</a> - </div> - <details v-if="note.files.length > 0"> - <summary>({{ this.$t('media-count').replace('{}', note.files.length) }})</summary> - <mk-media-list :media-list="note.files"/> - </details> - <details v-if="note.poll"> - <summary>{{ $t('poll') }}</summary> - <mk-poll :note="note"/> - </details> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/sub-note-content.vue'), - props: ['note'] -}); -</script> - -<style lang="stylus" scoped> -.mk-sub-note-content - overflow-wrap break-word - - > .body - > .reply - margin-right 6px - color #717171 - - > .rp - margin-left 4px - font-style oblique - color var(--renoteText) - - mk-poll - font-size 80% - -</style> diff --git a/src/client/app/desktop/views/components/ui-container.vue b/src/client/app/desktop/views/components/ui-container.vue deleted file mode 100644 index 59954fee8e..0000000000 --- a/src/client/app/desktop/views/components/ui-container.vue +++ /dev/null @@ -1,138 +0,0 @@ -<template> -<div class="kedshtep" :class="{ naked, inNakedDeckColumn, shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> - <header v-if="showHeader" :class="{ bodyTogglable }" @click="toggleContent(!showBody)"> - <div class="title"><slot name="header"></slot></div> - <slot name="func"></slot> - <button v-if="bodyTogglable"> - <template v-if="showBody"><fa icon="angle-up"/></template> - <template v-else><fa icon="angle-down"/></template> - </button> - </header> - <div v-show="showBody"> - <slot></slot> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - props: { - showHeader: { - type: Boolean, - default: true - }, - naked: { - type: Boolean, - default: false - }, - bodyTogglable: { - type: Boolean, - default: false - }, - expanded: { - type: Boolean, - default: true - }, - }, - inject: { - inNakedDeckColumn: { - default: false - } - }, - data() { - return { - showBody: this.expanded - }; - }, - methods: { - toggleContent(show: boolean) { - if (!this.bodyTogglable) return; - this.showBody = show; - this.$emit('toggle', show); - } - } -}); -</script> - -<style lang="stylus" scoped> -.kedshtep - overflow hidden - - &:not(.inNakedDeckColumn) - background var(--face) - - &.round - border-radius 6px - - &.shadow - box-shadow 0 3px 8px rgba(0, 0, 0, 0.2) - - & + .kedshtep - margin-top 16px - - &.naked - background transparent !important - box-shadow none !important - - > header - background var(--faceHeader) - - &.bodyTogglable - cursor pointer - - > .title - z-index 1 - margin 0 - padding 0 16px - line-height 42px - font-size 0.9em - font-weight bold - color var(--faceHeaderText) - box-shadow 0 var(--lineWidth) rgba(#000, 0.07) - - > [data-icon] - margin-right 6px - - &:empty - display none - - > button - position absolute - z-index 2 - top 0 - right 0 - padding 0 - width 42px - font-size 0.9em - line-height 42px - color var(--faceTextButton) - - &:hover - color var(--faceTextButtonHover) - - &:active - color var(--faceTextButtonActive) - - &.inNakedDeckColumn - background var(--face) - - > header - margin 0 - padding 8px 16px - font-size 12px - color var(--text) - background var(--deckColumnBg) - - &.bodyTogglable - cursor pointer - - > button - position absolute - top 0 - right 8px - padding 8px 6px - font-size 14px - color var(--text) - -</style> diff --git a/src/client/app/desktop/views/components/ui-notification.vue b/src/client/app/desktop/views/components/ui-notification.vue deleted file mode 100644 index 52e8e1d6cb..0000000000 --- a/src/client/app/desktop/views/components/ui-notification.vue +++ /dev/null @@ -1,62 +0,0 @@ -<template> -<div class="mk-ui-notification"> - <p>{{ message }}</p> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import anime from 'animejs'; - -export default Vue.extend({ - props: ['message'], - mounted() { - this.$nextTick(() => { - anime({ - targets: this.$el, - opacity: 1, - translateY: [-64, 0], - easing: 'easeOutElastic', - duration: 500 - }); - - setTimeout(() => { - anime({ - targets: this.$el, - opacity: 0, - translateY: -64, - duration: 500, - easing: 'easeInElastic', - complete: () => this.destroyDom() - }); - }, 5000); - }); - } -}); -</script> - -<style lang="stylus" scoped> -.mk-ui-notification - display block - position fixed - z-index 10000 - top -128px - left 0 - right 0 - margin 0 auto - padding 128px 0 0 0 - width 500px - color var(--desktopNotificationFg) - background var(--desktopNotificationBg) - border-radius 0 0 8px 8px - box-shadow 0 2px 4px var(--desktopNotificationShadow) - transform translateY(-64px) - opacity 0 - pointer-events none - - > p - margin 0 - line-height 64px - text-align center - -</style> diff --git a/src/client/app/desktop/views/components/ui.header.account.vue b/src/client/app/desktop/views/components/ui.header.account.vue deleted file mode 100644 index 690f3a5587..0000000000 --- a/src/client/app/desktop/views/components/ui.header.account.vue +++ /dev/null @@ -1,343 +0,0 @@ -<template> -<div class="account" v-hotkey.global="keymap"> - <button class="header" :data-active="isOpen" @click="toggle"> - <span class="username">{{ $store.state.i.username }}<template v-if="!isOpen"><fa icon="angle-down"/></template><template v-if="isOpen"><fa icon="angle-up"/></template></span> - <mk-avatar class="avatar" :user="$store.state.i"/> - </button> - <transition name="zoom-in-top"> - <div class="menu" v-if="isOpen"> - <ul> - <li> - <router-link :to="`/@${ $store.state.i.username }`"> - <i><fa icon="user" fixed-width/></i> - <span>{{ $t('profile') }}</span> - <i><fa icon="angle-right"/></i> - </router-link> - </li> - <li @click="drive"> - <p> - <i><fa icon="cloud" fixed-width/></i> - <span>{{ $t('@.drive') }}</span> - <i><fa icon="angle-right"/></i> - </p> - </li> - <li> - <router-link to="/i/favorites"> - <i><fa icon="star" fixed-width/></i> - <span>{{ $t('@.favorites') }}</span> - <i><fa icon="angle-right"/></i> - </router-link> - </li> - <li> - <router-link to="/i/lists"> - <i><fa icon="list" fixed-width/></i> - <span>{{ $t('lists') }}</span> - <i><fa icon="angle-right"/></i> - </router-link> - </li> - <li> - <router-link to="/i/groups"> - <i><fa :icon="faUsers" fixed-width/></i> - <span>{{ $t('groups') }}</span> - <i><fa icon="angle-right"/></i> - </router-link> - </li> - <li> - <router-link to="/i/pages"> - <i><fa :icon="faStickyNote" fixed-width/></i> - <span>{{ $t('@.pages') }}</span> - <i><fa icon="angle-right"/></i> - </router-link> - </li> - <li v-if="($store.state.i.isLocked || $store.state.i.carefulBot)"> - <router-link to="/i/follow-requests"> - <i><fa :icon="['far', 'envelope']" fixed-width/></i> - <span>{{ $t('follow-requests') }}<i v-if="$store.state.i.pendingReceivedFollowRequestsCount">{{ $store.state.i.pendingReceivedFollowRequestsCount }}</i></span> - <i><fa icon="angle-right"/></i> - </router-link> - </li> - <li> - <router-link :to="`/@${ $store.state.i.username }/room`"> - <i><fa :icon="faDoorOpen" fixed-width/></i> - <span>{{ $t('room') }}</span> - <i><fa icon="angle-right"/></i> - </router-link> - </li> - </ul> - <ul> - <li> - <router-link to="/i/settings"> - <i><fa icon="cog" fixed-width/></i> - <span>{{ $t('@.settings') }}</span> - <i><fa icon="angle-right"/></i> - </router-link> - </li> - <li v-if="$store.state.i.isAdmin || $store.state.i.isModerator"> - <a href="/admin"> - <i><fa icon="terminal" fixed-width/></i> - <span>{{ $t('admin') }}</span> - <i><fa icon="angle-right"/></i> - </a> - </li> - </ul> - <ul> - <li @click="toggleDeckMode"> - <p> - <template v-if="$store.state.device.inDeckMode"><span>{{ $t('@.home') }}</span><i><fa :icon="faHome"/></i></template> - <template v-else><span>{{ $t('@.deck') }}</span><i><fa :icon="faColumns"/></i></template> - </p> - </li> - <li @click="dark"> - <p> - <span>{{ $store.state.device.darkmode ? $t('@.turn-off-darkmode') : $t('@.turn-on-darkmode') }}</span> - <template><i><fa :icon="$store.state.device.darkmode ? faSun : faMoon"/></i></template> - </p> - </li> - </ul> - <ul> - <li @click="signout"> - <p class="signout"> - <i><fa icon="power-off" fixed-width/></i> - <span>{{ $t('@.signout') }}</span> - </p> - </li> - </ul> - </div> - </transition> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -// import MkSettingsWindow from './settings-window.vue'; -import MkDriveWindow from './drive-window.vue'; -import contains from '../../../common/scripts/contains'; -import { faHome, faColumns, faUsers, faDoorOpen } from '@fortawesome/free-solid-svg-icons'; -import { faMoon, faSun, faStickyNote } from '@fortawesome/free-regular-svg-icons'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/ui.header.account.vue'), - data() { - return { - isOpen: false, - faHome, faColumns, faMoon, faSun, faStickyNote, faUsers, faDoorOpen - }; - }, - computed: { - keymap(): any { - return { - 'a|m': this.toggle - }; - } - }, - beforeDestroy() { - this.close(); - }, - methods: { - toggle() { - this.isOpen ? this.close() : this.open(); - }, - open() { - this.isOpen = true; - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.addEventListener('mousedown', this.onMousedown); - } - }, - close() { - this.isOpen = false; - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.removeEventListener('mousedown', this.onMousedown); - } - }, - onMousedown(e) { - e.preventDefault(); - if (!contains(this.$el, e.target) && this.$el != e.target) this.close(); - return false; - }, - drive() { - this.close(); - this.$root.new(MkDriveWindow); - }, - signout() { - this.$root.signout(); - }, - dark() { - this.$store.commit('device/set', { - key: 'darkmode', - value: !this.$store.state.device.darkmode - }); - }, - toggleDeckMode() { - this.$store.commit('device/set', { key: 'deckMode', value: !this.$store.state.device.inDeckMode }); - location.replace('/'); - }, - } -}); -</script> - -<style lang="stylus" scoped> -.account - > .header - display block - margin 0 - padding 0 - color var(--desktopHeaderFg) - border none - background transparent - cursor pointer - - * - pointer-events none - - &:hover - &[data-active='true'] - color var(--desktopHeaderHoverFg) - - > .avatar - filter saturate(150%) - - > .username - display block - float left - margin 0 12px 0 16px - max-width 16em - line-height 48px - font-weight bold - text-decoration none - - @media (max-width 1100px) - display none - - [data-icon] - margin-left 8px - - > .avatar - display block - float left - min-width 32px - max-width 32px - min-height 32px - max-height 32px - margin 8px 8px 8px 0 - border-radius 4px - transition filter 100ms ease - - @media (max-width 1100px) - margin-left 8px - - > .menu - $bgcolor = var(--face) - display block - position absolute - top 56px - right -2px - width 230px - font-size 0.8em - background $bgcolor - border-radius 4px - box-shadow 0 var(--lineWidth) 4px rgba(#000, 0.25) - - &:before - content "" - pointer-events none - display block - position absolute - top -28px - right 12px - border-top solid 14px transparent - border-right solid 14px transparent - border-bottom solid 14px rgba(#000, 0.1) - border-left solid 14px transparent - - &:after - content "" - pointer-events none - display block - position absolute - top -27px - right 12px - border-top solid 14px transparent - border-right solid 14px transparent - border-bottom solid 14px $bgcolor - border-left solid 14px transparent - - ul - display block - margin 10px 0 - padding 0 - list-style none - - & + ul - padding-top 10px - border-top solid var(--lineWidth) var(--faceDivider) - - > li - display block - margin 0 - padding 0 - - > a - > p - display block - z-index 1 - padding 0 28px - margin 0 - line-height 40px - color var(--text) - cursor pointer - - * - pointer-events none - - > span:first-child - padding-left 22px - - > span:nth-child(2) - > i - margin-left 4px - padding 2px 8px - font-size 90% - font-style normal - background var(--primary) - color var(--primaryForeground) - border-radius 8px - - > i:first-child - margin-right 6px - width 16px - - > i:last-child - display block - position absolute - top 0 - right 8px - z-index 1 - padding 0 20px - font-size 1.2em - line-height 40px - - &:hover, &:active - text-decoration none - background var(--primary) - color var(--primaryForeground) - - &:active - background var(--primaryDarken10) - - &.signout - $color = #e64137 - - &:hover, &:active - background $color - color #fff - - &:active - background darken($color, 10%) - -.zoom-in-top-enter-active, -.zoom-in-top-leave-active { - transform-origin: center -16px; -} - -</style> diff --git a/src/client/app/desktop/views/components/ui.header.clock.vue b/src/client/app/desktop/views/components/ui.header.clock.vue deleted file mode 100644 index b8b638bc41..0000000000 --- a/src/client/app/desktop/views/components/ui.header.clock.vue +++ /dev/null @@ -1,109 +0,0 @@ -<template> -<div class="clock"> - <div class="header"> - <time ref="time"> - <span class="yyyymmdd">{{ yyyy }}/{{ mm }}/{{ dd }}</span> - <br> - <span class="hhnn">{{ hh }}<span :style="{ visibility: now.getSeconds() % 2 == 0 ? 'visible' : 'hidden' }">:</span>{{ nn }}</span> - </time> - </div> - <div class="content"> - <mk-analog-clock :dark="true"/> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - data() { - return { - now: new Date(), - clock: null - }; - }, - computed: { - yyyy(): number { - return this.now.getFullYear(); - }, - mm(): string { - return ('0' + (this.now.getMonth() + 1)).slice(-2); - }, - dd(): string { - return ('0' + this.now.getDate()).slice(-2); - }, - hh(): string { - return ('0' + this.now.getHours()).slice(-2); - }, - nn(): string { - return ('0' + this.now.getMinutes()).slice(-2); - } - }, - mounted() { - this.tick(); - this.clock = setInterval(this.tick, 1000); - }, - beforeDestroy() { - clearInterval(this.clock); - }, - methods: { - tick() { - this.now = new Date(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.clock - display inline-block - overflow visible - - > .header - padding 0 12px - text-align center - font-size 10px - - &, * - cursor: default - - &:hover - background #899492 - - & + .content - visibility visible - - > time - color #fff !important - - * - color #fff !important - - &:after - content "" - display block - clear both - - > time - display table-cell - vertical-align middle - height 48px - color var(--desktopHeaderFg) - - > .yyyymmdd - opacity 0.7 - - > .content - visibility hidden - display block - position absolute - top auto - right 0 - z-index 3 - margin 0 - padding 0 - width 256px - background #899492 - -</style> diff --git a/src/client/app/desktop/views/components/ui.header.messaging.vue b/src/client/app/desktop/views/components/ui.header.messaging.vue deleted file mode 100644 index c5d1da3a3d..0000000000 --- a/src/client/app/desktop/views/components/ui.header.messaging.vue +++ /dev/null @@ -1,69 +0,0 @@ -<template> -<div class="toltmoik"> - <button @click="open()" :title="$t('@.messaging')"> - <i class="bell"><fa :icon="faComments"/></i> - <i class="circle" v-if="hasUnreadMessagingMessage"><fa icon="circle"/></i> - </button> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import MkMessagingWindow from './messaging-window.vue'; -import { faComments } from '@fortawesome/free-regular-svg-icons'; - -export default Vue.extend({ - i18n: i18n(), - - data() { - return { - faComments - }; - }, - - computed: { - hasUnreadMessagingMessage(): boolean { - return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadMessagingMessage; - } - }, - - methods: { - open() { - this.$root.new(MkMessagingWindow); - }, - } -}); -</script> - -<style lang="stylus" scoped> -.toltmoik - > button - display block - margin 0 - padding 0 - width 32px - color var(--desktopHeaderFg) - border none - background transparent - cursor pointer - - * - pointer-events none - - &:hover - &[data-active='true'] - color var(--desktopHeaderHoverFg) - - > i.bell - font-size 1.2em - line-height 48px - - > i.circle - margin-left -5px - vertical-align super - font-size 10px - color var(--notificationIndicator) - animation blink 1s infinite - -</style> diff --git a/src/client/app/desktop/views/components/ui.header.nav.vue b/src/client/app/desktop/views/components/ui.header.nav.vue deleted file mode 100644 index 2bd3cf8772..0000000000 --- a/src/client/app/desktop/views/components/ui.header.nav.vue +++ /dev/null @@ -1,141 +0,0 @@ -<template> -<div class="nav"> - <ul> - <li class="timeline" :class="{ active: $route.name == 'index' }" @click="goToTop"> - <router-link to="/"><fa icon="home"/><p>{{ $t('@.timeline') }}</p></router-link> - </li> - <li class="featured" :class="{ active: $route.name == 'featured' }"> - <router-link to="/featured"><fa :icon="faNewspaper"/><p>{{ $t('@.featured-notes') }}</p></router-link> - </li> - <li class="explore" :class="{ active: $route.name == 'explore' || $route.name == 'explore-tag' }"> - <router-link to="/explore"><fa :icon="faHashtag"/><p>{{ $t('@.explore') }}</p></router-link> - </li> - <li class="game"> - <a @click="game"> - <fa icon="gamepad"/> - <p>{{ $t('game') }}</p> - <template v-if="hasGameInvitations"><fa icon="circle"/></template> - </a> - </li> - </ul> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import MkGameWindow from './game-window.vue'; -import { faNewspaper, faHashtag } from '@fortawesome/free-solid-svg-icons'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/ui.header.nav.vue'), - data() { - return { - hasGameInvitations: false, - connection: null, - faNewspaper, faHashtag - }; - }, - mounted() { - if (this.$store.getters.isSignedIn) { - this.connection = this.$root.stream.useSharedConnection('main'); - - this.connection.on('reversiInvited', this.onReversiInvited); - this.connection.on('reversiNoInvites', this.onReversiNoInvites); - } - }, - beforeDestroy() { - if (this.$store.getters.isSignedIn) { - this.connection.dispose(); - } - }, - methods: { - onReversiInvited() { - this.hasGameInvitations = true; - }, - - onReversiNoInvites() { - this.hasGameInvitations = false; - }, - - game() { - this.$root.new(MkGameWindow); - }, - - goToTop() { - window.scrollTo({ - top: 0, - behavior: 'smooth' - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.nav - display inline-block - margin 0 - padding 0 - line-height 3rem - vertical-align top - - > ul - display inline-block - margin 0 - padding 0 - vertical-align top - line-height 3rem - list-style none - - > li - display inline-block - vertical-align top - height 48px - line-height 48px - - &.active - > a - border-bottom solid 3px var(--primary) - - > a - display inline-block - z-index 1 - height 100% - padding 0 20px - font-size 13px - font-variant small-caps - color var(--desktopHeaderFg) - text-decoration none - transition none - cursor pointer - - * - pointer-events none - - &:hover - color var(--desktopHeaderHoverFg) - text-decoration none - - > [data-icon]:first-child - margin-right 8px - - > [data-icon]:last-child - margin-left 5px - font-size 10px - color var(--notificationIndicator) - - @media (max-width 1100px) - margin-left -5px - - > p - display inline - margin 0 - - @media (max-width 1100px) - display none - - @media (max-width 700px) - padding 0 12px - -</style> diff --git a/src/client/app/desktop/views/components/ui.header.notifications.vue b/src/client/app/desktop/views/components/ui.header.notifications.vue deleted file mode 100644 index d3316d6a89..0000000000 --- a/src/client/app/desktop/views/components/ui.header.notifications.vue +++ /dev/null @@ -1,136 +0,0 @@ -<template> -<div class="notifications" v-hotkey.global="keymap"> - <button :data-active="isOpen" @click="toggle" :title="$t('title')"> - <i class="bell"><fa :icon="['far', 'bell']"/></i> - <i class="circle" v-if="hasUnreadNotification"><fa icon="circle"/></i> - </button> - <div class="pop" v-if="isOpen"> - <mk-notifications/> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import contains from '../../../common/scripts/contains'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/ui.header.notifications.vue'), - data() { - return { - isOpen: false - }; - }, - - computed: { - hasUnreadNotification(): boolean { - return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadNotification; - }, - - keymap(): any { - return { - 'shift+n': this.toggle - }; - } - }, - - methods: { - toggle() { - this.isOpen ? this.close() : this.open(); - }, - - open() { - this.isOpen = true; - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.addEventListener('mousedown', this.onMousedown); - } - }, - - close() { - this.isOpen = false; - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.removeEventListener('mousedown', this.onMousedown); - } - }, - - onMousedown(e) { - e.preventDefault(); - if (!contains(this.$el, e.target) && this.$el != e.target) this.close(); - return false; - } - } -}); -</script> - -<style lang="stylus" scoped> -.notifications - > button - display block - margin 0 - padding 0 - width 32px - color var(--desktopHeaderFg) - border none - background transparent - cursor pointer - - * - pointer-events none - - &:hover - &[data-active='true'] - color var(--desktopHeaderHoverFg) - - > i.bell - font-size 1.2em - line-height 48px - - > i.circle - margin-left -5px - vertical-align super - font-size 10px - color var(--notificationIndicator) - animation blink 1s infinite - - > .pop - $bgcolor = var(--face) - display block - position absolute - top 56px - right -72px - width 300px - background $bgcolor - border-radius 4px - box-shadow 0 1px 4px rgba(#000, 0.25) - - &:before - content "" - pointer-events none - display block - position absolute - top -28px - right 74px - border-top solid 14px transparent - border-right solid 14px transparent - border-bottom solid 14px rgba(#000, 0.1) - border-left solid 14px transparent - - &:after - content "" - pointer-events none - display block - position absolute - top -27px - right 74px - border-top solid 14px transparent - border-right solid 14px transparent - border-bottom solid 14px $bgcolor - border-left solid 14px transparent - - > .mk-notifications - max-height 350px - font-size 1rem - overflow auto - -</style> diff --git a/src/client/app/desktop/views/components/ui.header.post.vue b/src/client/app/desktop/views/components/ui.header.post.vue deleted file mode 100644 index b273ad8d4d..0000000000 --- a/src/client/app/desktop/views/components/ui.header.post.vue +++ /dev/null @@ -1,54 +0,0 @@ -<template> -<div class="note"> - <button @click="post" :title="$t('post')"><fa icon="pencil-alt"/></button> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/ui.header.post.vue'), - methods: { - post() { - this.$post(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.note - display inline-block - padding 8px - height 100% - vertical-align top - - > button - display inline-block - margin 0 - padding 0 10px - height 100% - font-size 1.2em - font-weight normal - text-decoration none - color var(--primaryForeground) - background var(--primary) !important - outline none - border none - border-radius 4px - transition background 0.1s ease - cursor pointer - - * - pointer-events none - - &:hover - background var(--primaryLighten10) !important - - &:active - background var(--primaryDarken10) !important - transition background 0s ease - -</style> diff --git a/src/client/app/desktop/views/components/ui.header.search.vue b/src/client/app/desktop/views/components/ui.header.search.vue deleted file mode 100644 index 0cf5ca6f32..0000000000 --- a/src/client/app/desktop/views/components/ui.header.search.vue +++ /dev/null @@ -1,82 +0,0 @@ -<template> -<form class="wlvfdpkp" @submit.prevent="onSubmit"> - <i><fa icon="search"/></i> - <input v-model="q" type="search" :placeholder="$t('placeholder')" v-autocomplete="{ model: 'q' }"/> - <div class="result"></div> -</form> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { search } from '../../../common/scripts/search'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/ui.header.search.vue'), - data() { - return { - q: '', - wait: false - }; - }, - methods: { - async onSubmit() { - if (this.wait) return; - - this.wait = true; - search(this, this.q).finally(() => { - this.wait = false; - this.q = ''; - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.wlvfdpkp - @media (max-width 800px) - display none !important - - > i - display block - position absolute - top 0 - left 0 - width 48px - text-align center - line-height 48px - color var(--desktopHeaderFg) - pointer-events none - - > * - vertical-align middle - - > input - user-select text - cursor auto - margin 8px 0 0 0 - padding 6px 18px 6px 36px - width 14em - height 32px - font-size 1em - background var(--desktopHeaderSearchBg) - outline none - border none - border-radius 16px - transition color 0.5s ease, border 0.5s ease - color var(--desktopHeaderSearchFg) - - @media (max-width 1000px) - width 10em - - &::placeholder - color var(--desktopHeaderFg) - - &:hover - background var(--desktopHeaderSearchHoverBg) - - &:focus - box-shadow 0 0 0 2px var(--primaryAlpha05) !important - -</style> diff --git a/src/client/app/desktop/views/components/ui.header.vue b/src/client/app/desktop/views/components/ui.header.vue deleted file mode 100644 index 14a7321552..0000000000 --- a/src/client/app/desktop/views/components/ui.header.vue +++ /dev/null @@ -1,161 +0,0 @@ -<template> -<div class="header" :style="style"> - <p class="warn" v-if="env != 'production'">{{ $t('@.do-not-use-in-production') }} <a href="/assets/flush.html?force">Flush</a></p> - <div class="main" ref="main"> - <div class="backdrop"></div> - <div class="main"> - <div class="container" ref="mainContainer"> - <div class="left"> - <x-nav/> - </div> - <div class="center"> - <div class="icon" @click="goToTop"> - <img svg-inline src="../../assets/header-icon.svg"/> - </div> - </div> - <div class="right"> - <x-search/> - <x-account v-if="$store.getters.isSignedIn"/> - <x-messaging v-if="$store.getters.isSignedIn"/> - <x-notifications v-if="$store.getters.isSignedIn"/> - <x-post v-if="$store.getters.isSignedIn"/> - <x-clock v-if="$store.state.settings.showClockOnHeader" class="clock"/> - </div> - </div> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { env } from '../../../config'; - -import XNav from './ui.header.nav.vue'; -import XSearch from './ui.header.search.vue'; -import XAccount from './ui.header.account.vue'; -import XNotifications from './ui.header.notifications.vue'; -import XPost from './ui.header.post.vue'; -import XClock from './ui.header.clock.vue'; -import XMessaging from './ui.header.messaging.vue'; - -export default Vue.extend({ - i18n: i18n(), - components: { - XNav, - XSearch, - XAccount, - XNotifications, - XMessaging, - XPost, - XClock - }, - - data() { - return { - env: env - }; - }, - - computed: { - style(): any { - return { - 'box-shadow': this.$store.state.device.useShadow ? '0 0px 8px rgba(0, 0, 0, 0.2)' : 'none' - }; - } - }, - - mounted() { - this.$store.commit('setUiHeaderHeight', this.$el.offsetHeight); - }, - - methods: { - goToTop() { - window.scrollTo({ - top: 0, - behavior: 'smooth' - }); - } - }, -}); -</script> - -<style lang="stylus" scoped> -.header - position fixed - top 0 - z-index 1000 - width 100% - - > .warn - display block - margin 0 - padding 4px - text-align center - font-size 12px - background #f00 - color #fff - - > .main - height 48px - - > .backdrop - position absolute - top 0 - z-index 1000 - width 100% - height 48px - background var(--desktopHeaderBg) - - > .main - z-index 1001 - margin 0 - padding 0 - background-clip content-box - font-size 0.9rem - user-select none - - > .container - display flex - width 100% - max-width 1208px - margin 0 auto - - > * - position absolute - height 48px - - > .center - right 0 - - > .icon - margin auto - display block - width 48px - text-align center - cursor pointer - opacity 0.5 - - > svg - width 24px - height 48px - vertical-align top - fill var(--desktopHeaderFg) - - > .left, - > .center - left 0 - - > .right - right 0 - - > * - display inline-block - vertical-align top - - @media (max-width 1100px) - > .clock - display none - -</style> diff --git a/src/client/app/desktop/views/components/ui.sidebar.vue b/src/client/app/desktop/views/components/ui.sidebar.vue deleted file mode 100644 index d1ceec5198..0000000000 --- a/src/client/app/desktop/views/components/ui.sidebar.vue +++ /dev/null @@ -1,363 +0,0 @@ -<template> -<div class="header" :class="navbar" :data-shadow="$store.state.device.useShadow"> - <div class="body"> - <div class="post"> - <button @click="post" :title="$t('title')"><fa icon="pencil-alt"/></button> - </div> - - <div class="nav" v-if="$store.getters.isSignedIn"> - <div class="home" :class="{ active: $route.name == 'index' }" @click="goToTop"> - <router-link to="/"><fa icon="home"/></router-link> - </div> - <div class="featured" :class="{ active: $route.name == 'featured' }"> - <router-link to="/featured"><fa :icon="faNewspaper"/></router-link> - </div> - <div class="explore" :class="{ active: $route.name == 'explore' || $route.name == 'explore-tag' }"> - <router-link to="/explore"><fa :icon="faHashtag"/></router-link> - </div> - <div class="game"> - <a @click="game"><fa icon="gamepad"/><template v-if="hasGameInvitations"><fa icon="circle"/></template></a> - </div> - </div> - - <div class="nav bottom" v-if="$store.getters.isSignedIn"> - <div> - <a @click="drive"><fa icon="cloud"/></a> - </div> - <div ref="notificationsButton" :class="{ active: showNotifications }"> - <a @click="notifications"><fa :icon="['far', 'bell']"/></a> - </div> - <div class="messaging"> - <a @click="messaging"><fa icon="comments"/><template v-if="hasUnreadMessagingMessage"><fa icon="circle"/></template></a> - </div> - <div> - <a @click="settings"><fa icon="cog"/></a> - </div> - <div class="signout"> - <a @click="signout"><fa icon="power-off"/></a> - </div> - <div> - <router-link to="/i/favorites"><fa icon="star"/></router-link> - </div> - <div v-if="($store.state.i.isLocked || $store.state.i.carefulBot)"> - <a @click="followRequests"><fa :icon="['far', 'envelope']"/><i v-if="$store.state.i.pendingReceivedFollowRequestsCount">{{ $store.state.i.pendingReceivedFollowRequestsCount }}</i></a> - </div> - <div class="account"> - <router-link :to="`/@${ $store.state.i.username }`"> - <mk-avatar class="avatar" :user="$store.state.i"/> - </router-link> - </div> - <div> - <template v-if="$store.state.device.inDeckMode"> - <a @click="toggleDeckMode(false)"><fa icon="home"/></a> - </template> - <template v-else> - <a @click="toggleDeckMode(true)"><fa icon="columns"/></a> - </template> - </div> - <div> - <a @click="dark"><template v-if="$store.state.device.darkmode"><fa icon="moon"/></template><template v-else><fa :icon="['far', 'moon']"/></template></a> - </div> - </div> - </div> - - <transition :name="`slide-${navbar}`"> - <div class="notifications" v-if="showNotifications" ref="notifications" :class="navbar" :data-shadow="$store.state.device.useShadow"> - <mk-notifications/> - </div> - </transition> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import MkSettingsWindow from './settings-window.vue'; -import MkDriveWindow from './drive-window.vue'; -import MkMessagingWindow from './messaging-window.vue'; -import MkGameWindow from './game-window.vue'; -import contains from '../../../common/scripts/contains'; -import { faNewspaper, faHashtag } from '@fortawesome/free-solid-svg-icons'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/ui.sidebar.vue'), - data() { - return { - hasGameInvitations: false, - connection: null, - showNotifications: false, - faNewspaper, faHashtag - }; - }, - - computed: { - hasUnreadMessagingMessage(): boolean { - return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadMessagingMessage; - }, - - navbar(): string { - return this.$store.state.device.navbar; - }, - }, - - mounted() { - if (this.$store.getters.isSignedIn) { - this.connection = this.$root.stream.useSharedConnection('main'); - - this.connection.on('reversiInvited', this.onReversiInvited); - this.connection.on('reversiNoInvites', this.onReversiNoInvites); - } - }, - - beforeDestroy() { - if (this.$store.getters.isSignedIn) { - this.connection.dispose(); - } - }, - - methods: { - toggleDeckMode(deck) { - this.$store.commit('device/set', { key: 'deckMode', value: deck }); - location.replace('/'); - }, - - onReversiInvited() { - this.hasGameInvitations = true; - }, - - onReversiNoInvites() { - this.hasGameInvitations = false; - }, - - messaging() { - this.$root.new(MkMessagingWindow); - }, - - game() { - this.$root.new(MkGameWindow); - }, - - post() { - this.$post(); - }, - - drive() { - this.$root.new(MkDriveWindow); - }, - - list() { - this.$root.new(MkUserListsWindow); - }, - - followRequests() { - this.$root.new(MkFollowRequestsWindow); - }, - - settings() { - this.$root.new(MkSettingsWindow); - }, - - signout() { - this.$root.signout(); - }, - - notifications() { - this.showNotifications ? this.closeNotifications() : this.openNotifications(); - }, - - openNotifications() { - this.showNotifications = true; - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.addEventListener('mousedown', this.onMousedown); - } - }, - - closeNotifications() { - this.showNotifications = false; - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.removeEventListener('mousedown', this.onMousedown); - } - }, - - onMousedown(e) { - e.preventDefault(); - if ( - !contains(this.$refs.notifications, e.target) && - this.$refs.notifications != e.target && - !contains(this.$refs.notificationsButton, e.target) && - this.$refs.notificationsButton != e.target - ) { - this.closeNotifications(); - } - return false; - }, - - dark() { - this.$store.commit('device/set', { - key: 'darkmode', - value: !this.$store.state.device.darkmode - }); - }, - - goToTop() { - window.scrollTo({ - top: 0, - behavior: 'smooth' - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.header - $width = 68px - - position fixed - top 0 - z-index 1000 - width $width - height 100% - - &.left - left 0 - - &[data-shadow] - box-shadow 4px 0 4px rgba(0, 0, 0, 0.1) - - &.right - right 0 - - &[data-shadow] - box-shadow -4px 0 4px rgba(0, 0, 0, 0.1) - - > .body - position fixed - top 0 - z-index 1 - width $width - height 100% - background var(--desktopHeaderBg) - - > .post - width $width - height $width - padding 12px - - > button - display inline-block - margin 0 - padding 0 - height 100% - width 100% - font-size 1.2em - font-weight normal - text-decoration none - color var(--primaryForeground) - background var(--primary) !important - outline none - border none - border-radius 100% - transition background 0.1s ease - cursor pointer - - * - pointer-events none - - &:hover - background var(--primaryLighten10) !important - - &:active - background var(--primaryDarken10) !important - transition background 0s ease - - > .nav.bottom - position absolute - bottom 0 - left 0 - - > .account - width $width - height $width - padding 14px - - > * - display block - width 100% - height 100% - - > .avatar - pointer-events none - width 100% - height 100% - - > .notifications - position fixed - top 0 - width 350px - height 100% - overflow auto - background var(--face) - - &.left - left $width - - &[data-shadow] - box-shadow 4px 0 4px rgba(0, 0, 0, 0.1) - - &.right - right $width - - &[data-shadow] - box-shadow -4px 0 4px rgba(0, 0, 0, 0.1) - - .nav - > * - > * - display block - width $width - line-height 52px - text-align center - font-size 18px - color var(--desktopHeaderFg) - - &:hover - background rgba(0, 0, 0, 0.05) - color var(--desktopHeaderHoverFg) - text-decoration none - - &:active - background rgba(0, 0, 0, 0.1) - - &.left - .nav - > * - &.active - box-shadow -4px 0 var(--primary) inset - - &.right - .nav - > * - &.active - box-shadow 4px 0 var(--primary) inset - -.slide-left-enter-active, -.slide-left-leave-active { - transition: all 0.2s ease; -} - -.slide-left-enter, .slide-left-leave-to { - transform: translateX(-16px); - opacity: 0; -} - -.slide-right-enter-active, -.slide-right-leave-active { - transition: all 0.2s ease; -} - -.slide-right-enter, .slide-right-leave-to { - transform: translateX(16px); - opacity: 0; -} -</style> diff --git a/src/client/app/desktop/views/components/ui.vue b/src/client/app/desktop/views/components/ui.vue deleted file mode 100644 index f7961d5083..0000000000 --- a/src/client/app/desktop/views/components/ui.vue +++ /dev/null @@ -1,108 +0,0 @@ -<template> -<div class="mk-ui" v-hotkey.global="keymap"> - <div class="bg" v-if="$store.getters.isSignedIn && $store.state.settings.wallpaper" :style="style"></div> - <x-header class="header" v-if="navbar == 'top'" v-show="!zenMode" ref="header"/> - <x-sidebar class="sidebar" v-if="navbar != 'top'" v-show="!zenMode" ref="sidebar"/> - <div class="content" :class="[{ sidebar: navbar != 'top', zen: zenMode }, navbar]"> - <slot></slot> - </div> - <mk-stream-indicator v-if="$store.getters.isSignedIn"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import XHeader from './ui.header.vue'; -import XSidebar from './ui.sidebar.vue'; - -export default Vue.extend({ - components: { - XHeader, - XSidebar - }, - - data() { - return { - zenMode: false - }; - }, - - computed: { - navbar(): string { - return this.$store.state.device.navbar; - }, - - style(): any { - if (!this.$store.getters.isSignedIn || this.$store.state.settings.wallpaper == null) return {}; - return { - backgroundImage: `url(${ this.$store.state.settings.wallpaper })` - }; - }, - - keymap(): any { - return { - 'p': this.post, - 'n': this.post, - 'z': this.toggleZenMode - }; - } - }, - - watch: { - '$store.state.uiHeaderHeight'() { - this.$el.style.paddingTop = this.$store.state.uiHeaderHeight + 'px'; - }, - - navbar() { - if (this.navbar != 'top') { - this.$store.commit('setUiHeaderHeight', 0); - } - } - }, - - mounted() { - this.$el.style.paddingTop = this.$store.state.uiHeaderHeight + 'px'; - }, - - methods: { - post() { - this.$post(); - }, - - toggleZenMode() { - this.zenMode = !this.zenMode; - this.$nextTick(() => { - if (this.$refs.header) { - this.$store.commit('setUiHeaderHeight', this.$refs.header.$el.offsetHeight); - } - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-ui - min-height 100vh - padding-top 48px - - > .bg - position fixed - top 0 - left 0 - width 100% - height 100vh - background-size cover - background-position center - background-attachment fixed - - > .content.sidebar.left - padding-left 68px - - > .content.sidebar.right - padding-right 68px - - > .content.zen - padding 0 !important - -</style> diff --git a/src/client/app/desktop/views/components/user-list-timeline.vue b/src/client/app/desktop/views/components/user-list-timeline.vue deleted file mode 100644 index dae282ec5c..0000000000 --- a/src/client/app/desktop/views/components/user-list-timeline.vue +++ /dev/null @@ -1,71 +0,0 @@ -<template> -<div> - <mk-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"> - <template #header> - <slot></slot> - </template> - </mk-notes> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: ['list'], - data() { - return { - connection: null, - date: null, - pagination: { - endpoint: 'notes/user-list-timeline', - limit: 10, - params: init => ({ - listId: this.list.id, - untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes - }) - } - }; - }, - watch: { - $route: 'init' - }, - mounted() { - this.init(); - this.$root.$on('warp', this.warp); - this.$once('hook:beforeDestroy', () => { - this.$root.$off('warp', this.warp); - }); - }, - beforeDestroy() { - this.connection.dispose(); - }, - methods: { - init() { - if (this.connection) this.connection.dispose(); - this.connection = this.$root.stream.connectToChannel('userList', { - listId: this.list.id - }); - this.connection.on('note', this.onNote); - this.connection.on('userAdded', this.onUserAdded); - this.connection.on('userRemoved', this.onUserRemoved); - }, - onNote(note) { - (this.$refs.timeline as any).prepend(note); - }, - onUserAdded() { - (this.$refs.timeline as any).reload(); - }, - onUserRemoved() { - (this.$refs.timeline as any).reload(); - }, - warp(date) { - this.date = date; - (this.$refs.timeline as any).reload(); - } - } -}); -</script> diff --git a/src/client/app/desktop/views/components/user-preview.vue b/src/client/app/desktop/views/components/user-preview.vue deleted file mode 100644 index 9328648ccb..0000000000 --- a/src/client/app/desktop/views/components/user-preview.vue +++ /dev/null @@ -1,164 +0,0 @@ -<template> -<div class="mk-user-preview"> - <template v-if="u != null"> - <div class="banner" :style="u.bannerUrl ? `background-image: url(${u.bannerUrl})` : ''"></div> - <mk-avatar class="avatar" :user="u" :disable-preview="true"/> - <div class="title"> - <router-link class="name" :to="u | userPage"><mk-user-name :user="u" :nowrap="false"/></router-link> - <p class="username"><mk-acct :user="u"/></p> - </div> - <div class="description"> - <mfm v-if="u.description" :text="u.description" :author="u" :i="$store.state.i" :custom-emojis="u.emojis"/> - </div> - <div class="status"> - <div> - <p>{{ $t('notes') }}</p><span>{{ u.notesCount }}</span> - </div> - <div> - <p>{{ $t('following') }}</p><span>{{ u.followingCount }}</span> - </div> - <div> - <p>{{ $t('followers') }}</p><span>{{ u.followersCount }}</span> - </div> - </div> - <mk-follow-button class="koudoku-button" v-if="$store.getters.isSignedIn && u.id != $store.state.i.id" :user="u" mini/> - </template> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import anime from 'animejs'; -import parseAcct from '../../../../../misc/acct/parse'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/user-preview.vue'), - props: { - user: { - type: [Object, String], - required: true - } - }, - data() { - return { - u: null - }; - }, - mounted() { - if (typeof this.user == 'object') { - this.u = this.user; - this.$nextTick(() => { - this.open(); - }); - } else { - const query = this.user.startsWith('@') ? - parseAcct(this.user.substr(1)) : - { userId: this.user }; - - this.$root.api('users/show', query).then(user => { - this.u = user; - this.open(); - }); - } - }, - methods: { - open() { - anime({ - targets: this.$el, - opacity: 1, - 'margin-top': 0, - duration: 200, - easing: 'easeOutQuad' - }); - }, - close() { - anime({ - targets: this.$el, - opacity: 0, - 'margin-top': '-8px', - duration: 200, - easing: 'easeOutQuad', - complete: () => this.destroyDom() - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-user-preview - position absolute - z-index 2048 - margin-top -8px - width 250px - background var(--face) - background-clip content-box - border solid 1px rgba(#000, 0.1) - border-radius 4px - overflow hidden - opacity 0 - - > .banner - height 84px - background-color rgba(0, 0, 0, 0.1) - background-size cover - background-position center - - > .avatar - display block - position absolute - top 62px - left 13px - z-index 2 - width 58px - height 58px - border solid 3px var(--face) - border-radius 8px - - > .title - display block - padding 8px 0 8px 82px - - > .name - display inline-block - margin 0 - font-weight bold - line-height 16px - color var(--text) - - > .username - display block - margin 0 - line-height 16px - font-size 0.8em - color var(--text) - opacity 0.7 - - > .description - padding 0 16px - font-size 0.7em - color var(--text) - - > .status - padding 8px 16px - - > div - display inline-block - width 33% - - > p - margin 0 - font-size 0.7em - color var(--text) - - > span - font-size 1em - color var(--primary) - - > .koudoku-button - position absolute - top 8px - right 8px - -</style> diff --git a/src/client/app/desktop/views/components/window.vue b/src/client/app/desktop/views/components/window.vue deleted file mode 100644 index 499f4e7c91..0000000000 --- a/src/client/app/desktop/views/components/window.vue +++ /dev/null @@ -1,620 +0,0 @@ -<template> -<div class="mk-window" :data-flexible="isFlexible" @dragover="onDragover"> - <div class="bg" ref="bg" v-show="isModal" @click="onBgClick"></div> - <div class="main" ref="main" tabindex="-1" :data-is-modal="isModal" @mousedown="onBodyMousedown" @keydown="onKeydown" :style="{ width, height }"> - <div class="body"> - <header ref="header" - @contextmenu.prevent="() => {}" @mousedown.prevent="onHeaderMousedown" - > - <h1><slot name="header"></slot></h1> - <div> - <button class="popout" v-if="popoutUrl" @mousedown.stop="() => {}" @click="popout" :title="$t('popout')"> - <i><fa :icon="['far', 'window-restore']"/></i> - </button> - <button class="close" v-if="canClose" @mousedown.stop="() => {}" @click="close" :title="$t('close')"> - <i><fa icon="times"/></i> - </button> - </div> - </header> - <div class="content"> - <slot></slot> - </div> - </div> - <template v-if="canResize"> - <div class="handle top" @mousedown.prevent="onTopHandleMousedown"></div> - <div class="handle right" @mousedown.prevent="onRightHandleMousedown"></div> - <div class="handle bottom" @mousedown.prevent="onBottomHandleMousedown"></div> - <div class="handle left" @mousedown.prevent="onLeftHandleMousedown"></div> - <div class="handle top-left" @mousedown.prevent="onTopLeftHandleMousedown"></div> - <div class="handle top-right" @mousedown.prevent="onTopRightHandleMousedown"></div> - <div class="handle bottom-right" @mousedown.prevent="onBottomRightHandleMousedown"></div> - <div class="handle bottom-left" @mousedown.prevent="onBottomLeftHandleMousedown"></div> - </template> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import anime from 'animejs'; -import contains from '../../../common/scripts/contains'; - -const minHeight = 40; -const minWidth = 200; - -function dragListen(fn) { - window.addEventListener('mousemove', fn); - window.addEventListener('mouseleave', dragClear.bind(null, fn)); - window.addEventListener('mouseup', dragClear.bind(null, fn)); -} - -function dragClear(fn) { - window.removeEventListener('mousemove', fn); - window.removeEventListener('mouseleave', dragClear); - window.removeEventListener('mouseup', dragClear); -} - -export default Vue.extend({ - i18n: i18n('desktop/views/components/window.vue'), - props: { - isModal: { - type: Boolean, - default: false - }, - canClose: { - type: Boolean, - default: true - }, - width: { - type: String, - default: '530px' - }, - height: { - type: String, - default: 'auto' - }, - popoutUrl: { - type: [String, Function], - default: null - }, - name: { - type: String, - default: null - }, - animation: { - type: Boolean, - required: false, - default: true - } - }, - - computed: { - isFlexible(): boolean { - return this.height == 'auto'; - }, - canResize(): boolean { - return !this.isFlexible; - } - }, - - created() { - // ウィンドウをウィンドウシステムに登録 - this.$root.os.windows.add(this); - }, - - mounted() { - this.$nextTick(() => { - const main = this.$refs.main as any; - main.style.top = '15%'; - main.style.left = (window.innerWidth / 2) - (main.offsetWidth / 2) + 'px'; - - window.addEventListener('resize', this.onBrowserResize); - - this.open(); - }); - }, - - destroyed() { - // ウィンドウをウィンドウシステムから削除 - this.$root.os.windows.remove(this); - - window.removeEventListener('resize', this.onBrowserResize); - }, - - methods: { - open() { - this.$emit('opening'); - - this.top(); - - const bg = this.$refs.bg as any; - const main = this.$refs.main as any; - - if (this.isModal) { - bg.style.pointerEvents = 'auto'; - anime({ - targets: bg, - opacity: 1, - duration: this.animation ? 100 : 0, - easing: 'linear' - }); - } - - main.style.pointerEvents = 'auto'; - anime({ - targets: main, - opacity: 1, - scale: [1.1, 1], - duration: this.animation ? 200 : 0, - easing: 'easeOutQuad' - }); - - if (focus) main.focus(); - - setTimeout(() => { - this.$emit('opened'); - }, this.animation ? 300 : 0); - }, - - close() { - this.$emit('before-close'); - - const bg = this.$refs.bg as any; - const main = this.$refs.main as any; - - if (this.isModal) { - bg.style.pointerEvents = 'none'; - anime({ - targets: bg, - opacity: 0, - duration: this.animation ? 300 : 0, - easing: 'linear' - }); - } - - main.style.pointerEvents = 'none'; - - anime({ - targets: main, - opacity: 0, - scale: 0.8, - duration: this.animation ? 300 : 0, - easing: 'cubicBezier(0.5, -0.5, 1, 0.5)' - }); - - setTimeout(() => { - this.$emit('closed'); - this.destroyDom(); - }, this.animation ? 300 : 0); - }, - - popout() { - const url = typeof this.popoutUrl == 'function' ? this.popoutUrl() : this.popoutUrl; - - const main = this.$refs.main as any; - - if (main) { - const position = main.getBoundingClientRect(); - - const width = parseInt(getComputedStyle(main, '').width, 10); - const height = parseInt(getComputedStyle(main, '').height, 10); - const x = window.screenX + position.left; - const y = window.screenY + position.top; - - window.open(url, url, - `width=${width}, height=${height}, top=${y}, left=${x}`); - - this.close(); - } else { - const x = window.top.outerHeight / 2 + window.top.screenY - (parseInt(this.height, 10) / 2); - const y = window.top.outerWidth / 2 + window.top.screenX - (parseInt(this.width, 10) / 2); - window.open(url, url, - `width=${this.width}, height=${this.height}, top=${x}, left=${y}`); - } - }, - - // 最前面へ移動 - top() { - let z = 0; - - const ws = Array.from(this.$root.os.windows.getAll()).filter(w => w != this); - for (const w of ws) { - const m = w.$refs.main; - const mz = Number(document.defaultView.getComputedStyle(m, null).zIndex); - if (mz > z) z = mz; - } - - if (z > 0) { - (this.$refs.main as any).style.zIndex = z + 1; - if (this.isModal) (this.$refs.bg as any).style.zIndex = z + 1; - } - }, - - onBgClick() { - if (this.canClose) this.close(); - }, - - onBodyMousedown() { - this.top(); - }, - - onHeaderMousedown(e) { - const main = this.$refs.main as any; - - if (!contains(main, document.activeElement)) main.focus(); - - const position = main.getBoundingClientRect(); - - const clickX = e.clientX; - const clickY = e.clientY; - const moveBaseX = clickX - position.left; - const moveBaseY = clickY - position.top; - const browserWidth = window.innerWidth; - const browserHeight = window.innerHeight; - const windowWidth = main.offsetWidth; - const windowHeight = main.offsetHeight; - - // 動かした時 - dragListen(me => { - let moveLeft = me.clientX - moveBaseX; - let moveTop = me.clientY - moveBaseY; - - // 下はみ出し - if (moveTop + windowHeight > browserHeight) moveTop = browserHeight - windowHeight; - - // 左はみ出し - if (moveLeft < 0) moveLeft = 0; - - // 上はみ出し - if (moveTop < 0) moveTop = 0; - - // 右はみ出し - if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth; - - main.style.left = moveLeft + 'px'; - main.style.top = moveTop + 'px'; - }); - }, - - // 上ハンドル掴み時 - onTopHandleMousedown(e) { - const main = this.$refs.main as any; - - const base = e.clientY; - const height = parseInt(getComputedStyle(main, '').height, 10); - const top = parseInt(getComputedStyle(main, '').top, 10); - - // 動かした時 - dragListen(me => { - const move = me.clientY - base; - if (top + move > 0) { - if (height + -move > minHeight) { - this.applyTransformHeight(height + -move); - this.applyTransformTop(top + move); - } else { // 最小の高さより小さくなろうとした時 - this.applyTransformHeight(minHeight); - this.applyTransformTop(top + (height - minHeight)); - } - } else { // 上のはみ出し時 - this.applyTransformHeight(top + height); - this.applyTransformTop(0); - } - }); - }, - - // 右ハンドル掴み時 - onRightHandleMousedown(e) { - const main = this.$refs.main as any; - - const base = e.clientX; - const width = parseInt(getComputedStyle(main, '').width, 10); - const left = parseInt(getComputedStyle(main, '').left, 10); - const browserWidth = window.innerWidth; - - // 動かした時 - dragListen(me => { - const move = me.clientX - base; - if (left + width + move < browserWidth) { - if (width + move > minWidth) { - this.applyTransformWidth(width + move); - } else { // 最小の幅より小さくなろうとした時 - this.applyTransformWidth(minWidth); - } - } else { // 右のはみ出し時 - this.applyTransformWidth(browserWidth - left); - } - }); - }, - - // 下ハンドル掴み時 - onBottomHandleMousedown(e) { - const main = this.$refs.main as any; - - const base = e.clientY; - const height = parseInt(getComputedStyle(main, '').height, 10); - const top = parseInt(getComputedStyle(main, '').top, 10); - const browserHeight = window.innerHeight; - - // 動かした時 - dragListen(me => { - const move = me.clientY - base; - if (top + height + move < browserHeight) { - if (height + move > minHeight) { - this.applyTransformHeight(height + move); - } else { // 最小の高さより小さくなろうとした時 - this.applyTransformHeight(minHeight); - } - } else { // 下のはみ出し時 - this.applyTransformHeight(browserHeight - top); - } - }); - }, - - // 左ハンドル掴み時 - onLeftHandleMousedown(e) { - const main = this.$refs.main as any; - - const base = e.clientX; - const width = parseInt(getComputedStyle(main, '').width, 10); - const left = parseInt(getComputedStyle(main, '').left, 10); - - // 動かした時 - dragListen(me => { - const move = me.clientX - base; - if (left + move > 0) { - if (width + -move > minWidth) { - this.applyTransformWidth(width + -move); - this.applyTransformLeft(left + move); - } else { // 最小の幅より小さくなろうとした時 - this.applyTransformWidth(minWidth); - this.applyTransformLeft(left + (width - minWidth)); - } - } else { // 左のはみ出し時 - this.applyTransformWidth(left + width); - this.applyTransformLeft(0); - } - }); - }, - - // 左上ハンドル掴み時 - onTopLeftHandleMousedown(e) { - this.onTopHandleMousedown(e); - this.onLeftHandleMousedown(e); - }, - - // 右上ハンドル掴み時 - onTopRightHandleMousedown(e) { - this.onTopHandleMousedown(e); - this.onRightHandleMousedown(e); - }, - - // 右下ハンドル掴み時 - onBottomRightHandleMousedown(e) { - this.onBottomHandleMousedown(e); - this.onRightHandleMousedown(e); - }, - - // 左下ハンドル掴み時 - onBottomLeftHandleMousedown(e) { - this.onBottomHandleMousedown(e); - this.onLeftHandleMousedown(e); - }, - - // 高さを適用 - applyTransformHeight(height) { - (this.$refs.main as any).style.height = height + 'px'; - }, - - // 幅を適用 - applyTransformWidth(width) { - (this.$refs.main as any).style.width = width + 'px'; - }, - - // Y座標を適用 - applyTransformTop(top) { - (this.$refs.main as any).style.top = top + 'px'; - }, - - // X座標を適用 - applyTransformLeft(left) { - (this.$refs.main as any).style.left = left + 'px'; - }, - - onDragover(e) { - e.dataTransfer.dropEffect = 'none'; - }, - - onKeydown(e) { - if (e.which == 27) { // Esc - if (this.canClose) { - e.preventDefault(); - e.stopPropagation(); - this.close(); - } - } - }, - - onBrowserResize() { - const main = this.$refs.main as any; - const position = main.getBoundingClientRect(); - const browserWidth = window.innerWidth; - const browserHeight = window.innerHeight; - const windowWidth = main.offsetWidth; - const windowHeight = main.offsetHeight; - if (position.left < 0) main.style.left = 0; // 左はみ出し - if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し - if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し - if (position.top < 0) main.style.top = 0; // 上はみ出し - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-window - display block - - > .bg - display block - position fixed - z-index 2000 - top 0 - left 0 - width 100% - height 100% - background rgba(#000, 0.7) - opacity 0 - pointer-events none - - > .main - display block - position fixed - z-index 2000 - top 15% - left 0 - margin 0 - opacity 0 - pointer-events none - - &:focus - &:not([data-is-modal]) - > .body - box-shadow 0 0 0 1px var(--primaryAlpha05), 0 2px 12px 0 var(--desktopWindowShadow) - - > .handle - $size = 8px - - position absolute - - &.top - top -($size) - left 0 - width 100% - height $size - cursor ns-resize - - &.right - top 0 - right -($size) - width $size - height 100% - cursor ew-resize - - &.bottom - bottom -($size) - left 0 - width 100% - height $size - cursor ns-resize - - &.left - top 0 - left -($size) - width $size - height 100% - cursor ew-resize - - &.top-left - top -($size) - left -($size) - width $size * 2 - height $size * 2 - cursor nwse-resize - - &.top-right - top -($size) - right -($size) - width $size * 2 - height $size * 2 - cursor nesw-resize - - &.bottom-right - bottom -($size) - right -($size) - width $size * 2 - height $size * 2 - cursor nwse-resize - - &.bottom-left - bottom -($size) - left -($size) - width $size * 2 - height $size * 2 - cursor nesw-resize - - > .body - height 100% - overflow hidden - background var(--face) - border-radius 6px - box-shadow 0 2px 12px 0 rgba(#000, 0.5) - - > header - $header-height = 40px - - z-index 1001 - height $header-height - overflow hidden - white-space nowrap - cursor move - background var(--faceHeader) - border-radius 6px 6px 0 0 - box-shadow 0 1px 0 rgba(#000, 0.1) - - &, * - user-select none - - > h1 - pointer-events none - display block - margin 0 auto - overflow hidden - height $header-height - text-overflow ellipsis - text-align center - font-size 1em - line-height $header-height - font-weight normal - color var(--desktopWindowTitle) - - > div:last-child - position absolute - top 0 - right 0 - display block - z-index 1 - - > * - display inline-block - margin 0 - padding 0 - cursor pointer - font-size 1em - color var(--faceTextButton) - border none - outline none - background transparent - - &:hover - color var(--faceTextButtonHover) - - &:active - color var(--faceTextButtonActive) - - > i - display inline-block - padding 0 - width $header-height - line-height $header-height - text-align center - - > .content - height 100% - overflow auto - - &:not([flexible]) - > .main > .body > .content - height calc(100% - 40px) - -</style> diff --git a/src/client/app/desktop/views/home/home.vue b/src/client/app/desktop/views/home/home.vue deleted file mode 100644 index ec166d42c8..0000000000 --- a/src/client/app/desktop/views/home/home.vue +++ /dev/null @@ -1,406 +0,0 @@ -<template> -<component :is="customize ? 'mk-dummy' : 'mk-ui'" v-hotkey.global="keymap" v-if="$store.getters.isSignedIn || $route.name != 'index'"> - <div class="wqsofvpm" :data-customize="customize"> - <div class="customize" v-if="customize"> - <a @click="done()"><fa icon="check"/>{{ $t('done') }}</a> - <div> - <div class="adder"> - <p>{{ $t('add-widget') }}</p> - <select v-model="widgetAdderSelected"> - <option value="profile">{{ $t('@.widgets.profile') }}</option> - <option value="analog-clock">{{ $t('@.widgets.analog-clock') }}</option> - <option value="calendar">{{ $t('@.widgets.calendar') }}</option> - <option value="timemachine">{{ $t('@.widgets.timemachine') }}</option> - <option value="activity">{{ $t('@.widgets.activity') }}</option> - <option value="rss">{{ $t('@.widgets.rss') }}</option> - <option value="trends">{{ $t('@.widgets.trends') }}</option> - <option value="photo-stream">{{ $t('@.widgets.photo-stream') }}</option> - <option value="slideshow">{{ $t('@.widgets.slideshow') }}</option> - <option value="version">{{ $t('@.widgets.version') }}</option> - <option value="broadcast">{{ $t('@.widgets.broadcast') }}</option> - <option value="notifications">{{ $t('@.widgets.notifications') }}</option> - <option value="users">{{ $t('@.widgets.users') }}</option> - <option value="polls">{{ $t('@.widgets.polls') }}</option> - <option value="post-form">{{ $t('@.widgets.post-form') }}</option> - <option value="messaging">{{ $t('@.messaging') }}</option> - <option value="memo">{{ $t('@.widgets.memo') }}</option> - <option value="hashtags">{{ $t('@.widgets.hashtags') }}</option> - <option value="posts-monitor">{{ $t('@.widgets.posts-monitor') }}</option> - <option value="server">{{ $t('@.widgets.server') }}</option> - <option value="queue">{{ $t('@.widgets.queue') }}</option> - <option value="nav">{{ $t('@.widgets.nav') }}</option> - <option value="tips">{{ $t('@.widgets.tips') }}</option> - </select> - <button @click="addWidget">{{ $t('add') }}</button> - </div> - <div class="trash"> - <x-draggable v-model="trash" group="x" @add="onTrash"></x-draggable> - <p>{{ $t('@.trash') }}</p> - </div> - </div> - </div> - <div class="main" :class="{ side: widgets.left.length == 0 || widgets.right.length == 0 }"> - <template v-if="customize"> - <x-draggable v-for="place in ['left', 'right']" - :list="widgets[place]" - :class="place" - :data-place="place" - group="x" - animation="150" - @sort="onWidgetSort" - :key="place" - > - <div v-for="widget in widgets[place]" class="customize-container" :key="widget.id" @contextmenu.stop.prevent="onWidgetContextmenu(widget.id)"> - <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" platform="desktop"/> - </div> - </x-draggable> - <div class="main"> - <a @click="hint">{{ $t('@.customization-tips.title') }}</a> - <div> - <x-timeline/> - </div> - </div> - </template> - <template v-else> - <div v-for="place in ['left', 'right']" :class="place" :key="place"> - <component v-for="widget in widgets[place]" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" platform="desktop"/> - </div> - <div class="main"> - <router-view ref="content"></router-view> - </div> - </template> - </div> - </div> -</component> -<x-welcome v-else/> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import * as XDraggable from 'vuedraggable'; -import { v4 as uuid } from 'uuid'; -import XWelcome from '../pages/welcome.vue'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/home.vue'), - - components: { - XDraggable, - XWelcome - }, - - data() { - return { - customize: window.location.search == '?customize', - connection: null, - widgetAdderSelected: null, - trash: [], - view: null - }; - }, - - computed: { - home(): any[] { - if (this.$store.getters.isSignedIn) { - return this.$store.getters.home || []; - } else { - return [{ - name: 'instance', - place: 'right' - }, { - name: 'broadcast', - place: 'right', - data: {} - }, { - name: 'hashtags', - place: 'right', - data: {} - }]; - } - }, - left(): any[] { - return this.home.filter(w => w.place == 'left'); - }, - right(): any[] { - return this.home.filter(w => w.place == 'right'); - }, - widgets(): any { - return { - left: this.left, - right: this.right - }; - }, - keymap(): any { - return { - 't': this.focus - }; - } - }, - - created() { - if (!this.$store.getters.isSignedIn) return; - - if (this.$store.getters.home == null) { - const defaultDesktopHomeWidgets = { - left: [ - 'profile', - 'calendar', - 'activity', - 'rss', - 'hashtags', - 'photo-stream', - 'version' - ], - right: [ - 'customize', - 'broadcast', - 'notifications', - 'users', - 'polls', - 'server', - 'nav', - 'tips' - ] - }; - - //#region Construct home data - const _defaultDesktopHomeWidgets = []; - - for (const widget of defaultDesktopHomeWidgets.left) { - _defaultDesktopHomeWidgets.push({ - name: widget, - id: uuid(), - place: 'left', - data: {} - }); - } - - for (const widget of defaultDesktopHomeWidgets.right) { - _defaultDesktopHomeWidgets.push({ - name: widget, - id: uuid(), - place: 'right', - data: {} - }); - } - //#endregion - - this.$store.commit('setHome', _defaultDesktopHomeWidgets); - } - }, - - mounted() { - this.connection = this.$root.stream.useSharedConnection('main'); - }, - - beforeDestroy() { - this.connection.dispose(); - }, - - methods: { - hint() { - this.$root.dialog({ - title: this.$t('@.customization-tips.title'), - text: this.$t('@.customization-tips.paragraph') - }); - }, - - onTlLoaded() { - this.$emit('loaded'); - }, - - onWidgetContextmenu(widgetId) { - const w = (this.$refs[widgetId] as any)[0]; - if (w.func) w.func(); - }, - - onWidgetSort() { - this.saveHome(); - }, - - onTrash(evt) { - this.saveHome(); - }, - - addWidget() { - if(this.widgetAdderSelected == null) return; - - this.$store.commit('addHomeWidget', { - name: this.widgetAdderSelected, - id: uuid(), - place: 'left', - data: {} - }); - }, - - saveHome() { - const left = this.widgets.left; - const right = this.widgets.right; - this.$store.commit('setHome', left.concat(right)); - for (const w of left) w.place = 'left'; - for (const w of right) w.place = 'right'; - }, - - done() { - location.href = '/'; - }, - - focus() { - (this.$refs.content as any).focus(); - }, - } -}); -</script> - -<style lang="stylus" scoped> -.wqsofvpm - display block - - &[data-customize] - padding-top 48px - background-image url('/assets/desktop/grid.svg') - - > .main > .main - > a - display block - margin-bottom 8px - text-align center - - > div - cursor not-allowed !important - - > * - pointer-events none - - &:not([data-customize]) - > .main > *:not(.main):empty - display none - - > .customize - position fixed - z-index 1000 - top 0 - left 0 - width 100% - height 48px - color var(--text) - background var(--desktopHeaderBg) - box-shadow 0 1px 1px rgba(#000, 0.075) - - > a - display block - position absolute - z-index 1001 - top 0 - right 0 - padding 0 16px - line-height 48px - text-decoration none - color var(--primaryForeground) - background var(--primary) - transition background 0.1s ease - - &:hover - background var(--primaryLighten10) - - &:active - background var(--primaryDarken10) - transition background 0s ease - - > [data-icon] - margin-right 8px - - > div - display flex - margin 0 auto - max-width 1220px - 32px - - > div - width 50% - - &.adder - > p - display inline - line-height 48px - - &.trash - border-left solid 1px var(--faceDivider) - - > div - width 100% - height 100% - - > p - position absolute - top 0 - left 0 - width 100% - line-height 48px - margin 0 - text-align center - pointer-events none - - > .main - display flex - justify-content center - margin 0 auto - max-width 1240px - - > * - .customize-container - cursor move - border-radius 6px - - &:hover - box-shadow 0 0 8px rgba(64, 120, 200, 0.3) - - > * - pointer-events none - - > .main - padding 16px - width calc(100% - 280px * 2) - order 2 - - &.side - > .main - width calc(100% - 280px) - max-width 680px - - > *:not(.main) - width 280px - padding 16px 0 16px 0 - - > *:not(:last-child) - margin-bottom 16px - - > .left - padding-left 16px - order 1 - - > .right - padding-right 16px - order 3 - - &.side - @media (max-width 1000px) - > *:not(.main) - display none - - > .main - width 100% - max-width 700px - margin 0 auto - - &:not(.side) - @media (max-width 1100px) - > *:not(.main) - display none - - > .main - width 100% - max-width 700px - margin 0 auto - -</style> diff --git a/src/client/app/desktop/views/home/note.vue b/src/client/app/desktop/views/home/note.vue deleted file mode 100644 index c19f58cd2b..0000000000 --- a/src/client/app/desktop/views/home/note.vue +++ /dev/null @@ -1,59 +0,0 @@ -<template> -<div v-if="!fetching" class="kcthdwmv"> - <mk-note-detail :note="note" :key="note.id"/> - <footer> - <router-link v-if="note.next" :to="note.next"><fa icon="angle-left"/> {{ $t('next') }}</router-link> - <router-link v-if="note.prev" :to="note.prev">{{ $t('prev') }} <fa icon="angle-right"/></router-link> - </footer> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import Progress from '../../../common/scripts/loading'; - -export default Vue.extend({ - i18n: i18n('desktop/views/pages/note.vue'), - data() { - return { - fetching: true, - note: null - }; - }, - watch: { - $route: 'fetch' - }, - created() { - this.fetch(); - }, - methods: { - fetch() { - Progress.start(); - this.fetching = true; - - this.$root.api('notes/show', { - noteId: this.$route.params.note - }).then(note => { - this.note = note; - this.fetching = false; - - Progress.done(); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.kcthdwmv - text-align center - - > footer - margin-top 16px - - > a - display inline-block - margin 0 16px - -</style> diff --git a/src/client/app/desktop/views/home/search.vue b/src/client/app/desktop/views/home/search.vue deleted file mode 100644 index 06b354b133..0000000000 --- a/src/client/app/desktop/views/home/search.vue +++ /dev/null @@ -1,76 +0,0 @@ -<template> -<div> - <mk-notes ref="timeline" :pagination="pagination" @inited="inited"> - <template #header> - <header class="oxgbmvii"> - <span><fa icon="search"/> {{ q }}</span> - </header> - </template> - </mk-notes> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import Progress from '../../../common/scripts/loading'; -import { genSearchQuery } from '../../../common/scripts/gen-search-query'; - -export default Vue.extend({ - i18n: i18n('desktop/views/pages/search.vue'), - data() { - return { - pagination: { - endpoint: 'notes/search', - limit: 20, - params: () => genSearchQuery(this, this.q) - } - }; - }, - computed: { - q(): string { - return this.$route.query.q; - } - }, - watch: { - $route() { - this.$refs.timeline.reload(); - } - }, - mounted() { - document.addEventListener('keydown', this.onDocumentKeydown); - window.addEventListener('scroll', this.onScroll, { passive: true }); - Progress.start(); - }, - beforeDestroy() { - document.removeEventListener('keydown', this.onDocumentKeydown); - window.removeEventListener('scroll', this.onScroll); - }, - methods: { - onDocumentKeydown(e) { - if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { - if (e.which == 84) { // t - (this.$refs.timeline as any).focus(); - } - } - }, - inited() { - Progress.done(); - }, - } -}); -</script> - -<style lang="stylus" scoped> -.oxgbmvii - padding 0 8px - z-index 10 - background var(--faceHeader) - box-shadow 0 var(--lineWidth) var(--desktopTimelineHeaderShadow) - - > span - padding 0 8px - font-size 0.9em - line-height 42px - color var(--text) -</style> diff --git a/src/client/app/desktop/views/home/tag.vue b/src/client/app/desktop/views/home/tag.vue deleted file mode 100644 index 343b4ce951..0000000000 --- a/src/client/app/desktop/views/home/tag.vue +++ /dev/null @@ -1,65 +0,0 @@ -<template> -<div> - <mk-notes ref="timeline" :pagination="pagination" @loaded="inited"> - <template #header> - <header class="wqraeznr"> - <span><fa icon="hashtag"/> {{ $route.params.tag }}</span> - </header> - </template> - </mk-notes> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import Progress from '../../../common/scripts/loading'; - -export default Vue.extend({ - i18n: i18n('desktop/views/pages/tag.vue'), - computed: { - pagination() { - return { - endpoint: 'notes/search-by-tag', - limit: 20, - params: { - tag: this.$route.params.tag - } - }; - } - }, - mounted() { - document.addEventListener('keydown', this.onDocumentKeydown); - Progress.start(); - }, - beforeDestroy() { - document.removeEventListener('keydown', this.onDocumentKeydown); - }, - methods: { - onDocumentKeydown(e) { - if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { - if (e.which == 84) { // t - (this.$refs.timeline as any).focus(); - } - } - }, - inited() { - Progress.done(); - }, - } -}); -</script> - -<style lang="stylus" scoped> -.wqraeznr - padding 0 8px - z-index 10 - background var(--faceHeader) - box-shadow 0 var(--lineWidth) var(--desktopTimelineHeaderShadow) - - > span - padding 0 8px - font-size 0.9em - line-height 42px - color var(--text) -</style> diff --git a/src/client/app/desktop/views/home/timeline.core.vue b/src/client/app/desktop/views/home/timeline.core.vue deleted file mode 100644 index aae7dbc60e..0000000000 --- a/src/client/app/desktop/views/home/timeline.core.vue +++ /dev/null @@ -1,144 +0,0 @@ -<template> -<div> - <mk-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"> - <template #header> - <slot></slot> - <div v-if="src == 'home' && alone" class="ibpylqas"> - <p>{{ $t('@.empty-timeline-info.follow-users-to-make-your-timeline') }}</p> - <router-link to="/explore">{{ $t('@.empty-timeline-info.explore') }}</router-link> - </div> - </template> - </mk-notes> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/timeline.core.vue'), - - props: { - src: { - type: String, - required: true - }, - tagTl: { - required: false - } - }, - - data() { - return { - connection: null, - date: null, - baseQuery: { - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes - }, - query: {}, - endpoint: null, - pagination: null - }; - }, - - computed: { - alone(): boolean { - return this.$store.state.i.followingCount == 0; - } - }, - - created() { - this.$root.$on('warp', this.warp); - this.$once('hook:beforeDestroy', () => { - this.$root.$off('warp', this.warp); - this.connection.dispose(); - }); - - const prepend = note => { - (this.$refs.timeline as any).prepend(note); - }; - - if (this.src == 'tag') { - this.endpoint = 'notes/search-by-tag'; - this.query = { - query: this.tagTl.query - }; - this.connection = this.$root.stream.connectToChannel('hashtag', { q: this.tagTl.query }); - this.connection.on('note', prepend); - } else if (this.src == 'home') { - this.endpoint = 'notes/timeline'; - const onChangeFollowing = () => { - this.fetch(); - }; - this.connection = this.$root.stream.useSharedConnection('homeTimeline'); - this.connection.on('note', prepend); - this.connection.on('follow', onChangeFollowing); - this.connection.on('unfollow', onChangeFollowing); - } else if (this.src == 'local') { - this.endpoint = 'notes/local-timeline'; - this.connection = this.$root.stream.useSharedConnection('localTimeline'); - this.connection.on('note', prepend); - } else if (this.src == 'hybrid') { - this.endpoint = 'notes/hybrid-timeline'; - this.connection = this.$root.stream.useSharedConnection('hybridTimeline'); - this.connection.on('note', prepend); - } else if (this.src == 'global') { - this.endpoint = 'notes/global-timeline'; - this.connection = this.$root.stream.useSharedConnection('globalTimeline'); - this.connection.on('note', prepend); - } else if (this.src == 'mentions') { - this.endpoint = 'notes/mentions'; - this.connection = this.$root.stream.useSharedConnection('main'); - this.connection.on('mention', prepend); - } else if (this.src == 'messages') { - this.endpoint = 'notes/mentions'; - this.query = { - visibility: 'specified' - }; - const onNote = note => { - if (note.visibility == 'specified') { - prepend(note); - } - }; - this.connection = this.$root.stream.useSharedConnection('main'); - this.connection.on('mention', onNote); - } - - this.pagination = { - endpoint: this.endpoint, - limit: 10, - params: init => ({ - untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), - ...this.baseQuery, ...this.query - }) - }; - }, - - methods: { - focus() { - (this.$refs.timeline as any).focus(); - }, - - warp(date) { - this.date = date; - (this.$refs.timeline as any).reload(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.ibpylqas - padding 16px - text-align center - color var(--text) - border-bottom solid var(--lineWidth) var(--faceDivider) - font-size 14px - - > p - margin 0 0 8px 0 - -</style> diff --git a/src/client/app/desktop/views/home/timeline.vue b/src/client/app/desktop/views/home/timeline.vue deleted file mode 100644 index 224b937997..0000000000 --- a/src/client/app/desktop/views/home/timeline.vue +++ /dev/null @@ -1,278 +0,0 @@ -<template> -<div class="pwbzawku"> - <x-post-form class="form" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }" v-if="$store.state.settings.showPostFormOnTopOfTl"/> - <div class="main"> - <component :is="src == 'list' ? 'mk-user-list-timeline' : 'x-core'" ref="tl" v-bind="options"> - <header class="zahtxcqi"> - <div :data-active="src == 'home'" @click="src = 'home'"><fa icon="home"/> {{ $t('home') }}</div> - <div :data-active="src == 'local'" @click="src = 'local'" v-if="enableLocalTimeline"><fa :icon="['far', 'comments']"/> {{ $t('local') }}</div> - <div :data-active="src == 'hybrid'" @click="src = 'hybrid'" v-if="enableLocalTimeline"><fa icon="share-alt"/> {{ $t('hybrid') }}</div> - <div :data-active="src == 'global'" @click="src = 'global'" v-if="enableGlobalTimeline"><fa icon="globe"/> {{ $t('global') }}</div> - <div :data-active="src == 'tag'" @click="src = 'tag'" v-if="tagTl"><fa icon="hashtag"/> {{ tagTl.title }}</div> - <div :data-active="src == 'list'" @click="src = 'list'" v-if="list"><fa icon="list"/> {{ list.name }}</div> - <div class="buttons"> - <button :data-active="src == 'mentions'" @click="src = 'mentions'" :title="$t('mentions')"><fa icon="at"/><i class="indicator" v-if="$store.state.i.hasUnreadMentions"><fa icon="circle"/></i></button> - <button :data-active="src == 'messages'" @click="src = 'messages'" :title="$t('messages')"><fa :icon="['far', 'envelope']"/><i class="indicator" v-if="$store.state.i.hasUnreadSpecifiedNotes"><fa icon="circle"/></i></button> - <button @click="chooseTag" :title="$t('hashtag')" ref="tagButton"><fa icon="hashtag"/></button> - <button @click="chooseList" :title="$t('list')" ref="listButton"><fa icon="list"/></button> - </div> - </header> - </component> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XCore from './timeline.core.vue'; -import Menu from '../../../common/views/components/menu.vue'; -import MkSettingsWindow from '../components/settings-window.vue'; - -export default Vue.extend({ - i18n: i18n('desktop/views/components/timeline.vue'), - - components: { - XCore, - XPostForm: () => import('../components/post-form.vue').then(m => m.default) - }, - - data() { - return { - src: 'home', - list: null, - tagTl: null, - enableLocalTimeline: false, - enableGlobalTimeline: false, - }; - }, - - computed: { - options(): any { - return { - ...(this.src == 'list' ? { list: this.list } : { src: this.src }), - ...(this.src == 'tag' ? { tagTl: this.tagTl } : {}), - key: this.src == 'list' ? this.list.id : this.src - } - } - }, - - watch: { - src() { - this.saveSrc(); - }, - - list(x) { - this.saveSrc(); - if (x != null) this.tagTl = null; - }, - - tagTl(x) { - this.saveSrc(); - if (x != null) this.list = null; - } - }, - - created() { - this.$root.getMeta().then((meta: Record<string, any>) => { - if (!( - this.enableGlobalTimeline = !meta.disableGlobalTimeline || this.$store.state.i.isModerator || this.$store.state.i.isAdmin - ) && this.src === 'global') this.src = 'local'; - if (!( - this.enableLocalTimeline = !meta.disableLocalTimeline || this.$store.state.i.isModerator || this.$store.state.i.isAdmin - ) && ['local', 'hybrid'].includes(this.src)) this.src = 'home'; - }); - - if (this.$store.state.device.tl) { - this.src = this.$store.state.device.tl.src; - if (this.src == 'list') { - this.list = this.$store.state.device.tl.arg; - } else if (this.src == 'tag') { - this.tagTl = this.$store.state.device.tl.arg; - } - } - }, - - mounted() { - document.title = this.$root.instanceName; - - (this.$refs.tl as any).$once('loaded', () => { - this.$emit('loaded'); - }); - }, - - methods: { - saveSrc() { - this.$store.commit('device/setTl', { - src: this.src, - arg: this.src == 'list' ? this.list : this.tagTl - }); - }, - - focus() { - (this.$refs.tl as any).focus(); - }, - - warp(date) { - (this.$refs.tl as any).warp(date); - }, - - async chooseList() { - const lists = await this.$root.api('users/lists/list'); - - let menu = [{ - icon: 'plus', - text: this.$t('add-list'), - action: () => { - this.$root.dialog({ - title: this.$t('list-name'), - input: true - }).then(async ({ canceled, result: name }) => { - if (canceled) return; - const list = await this.$root.api('users/lists/create', { - name - }); - - this.list = list; - this.src = 'list'; - }); - } - }]; - - if (lists.length > 0) { - menu.push(null); - } - - menu = menu.concat(lists.map(list => ({ - icon: 'list', - text: list.name, - action: () => { - this.list = list; - this.src = 'list'; - } - }))); - - this.$root.new(Menu, { - source: this.$refs.listButton, - items: menu - }); - }, - - chooseTag() { - let menu = [{ - icon: 'plus', - text: this.$t('add-tag-timeline'), - action: () => { - this.$root.new(MkSettingsWindow, { - initialPage: 'hashtags' - }); - } - }]; - - if (this.$store.state.settings.tagTimelines.length > 0) { - menu.push(null); - } - - menu = menu.concat(this.$store.state.settings.tagTimelines.map(t => ({ - icon: 'hashtag', - text: t.title, - action: () => { - this.tagTl = t; - this.src = 'tag'; - } - }))); - - this.$root.new(Menu, { - source: this.$refs.tagButton, - items: menu - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.pwbzawku - > .form - margin-bottom 16px - - &.round - border-radius 6px - - &.shadow - box-shadow 0 3px 8px rgba(0, 0, 0, 0.2) - - header.zahtxcqi - display flex - flex-wrap wrap - padding 0 8px - z-index 10 - background var(--faceHeader) - box-shadow 0 var(--lineWidth) var(--desktopTimelineHeaderShadow) - - > * - flex-shrink 0 - - > .buttons - margin-left auto - - > button - padding 0 8px - font-size 0.9em - line-height 42px - color var(--faceTextButton) - - > .indicator - position absolute - top -4px - right 4px - font-size 10px - color var(--notificationIndicator) - animation blink 1s infinite - - &:hover - color var(--faceTextButtonHover) - - &[data-active] - color var(--primary) - cursor default - - &:before - content "" - display block - position absolute - bottom 0 - left 0 - width 100% - height 2px - background var(--primary) - - > div:not(.buttons) - padding 0 10px - line-height 42px - font-size 12px - user-select none - - &[data-active] - color var(--primary) - cursor default - font-weight bold - - &:before - content "" - display block - position absolute - bottom 0 - left -8px - width calc(100% + 16px) - height 2px - background var(--primary) - - &:not([data-active]) - color var(--desktopTimelineSrc) - cursor pointer - - &:hover - color var(--desktopTimelineSrcHover) - -</style> diff --git a/src/client/app/desktop/views/home/user/index.vue b/src/client/app/desktop/views/home/user/index.vue deleted file mode 100644 index 98ad165d93..0000000000 --- a/src/client/app/desktop/views/home/user/index.vue +++ /dev/null @@ -1,90 +0,0 @@ -<template> -<div class="omechnps" v-if="!fetching"> - <div class="is-suspended" v-if="user.isSuspended" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> - <fa icon="exclamation-triangle"/> {{ $t('@.user-suspended') }} - </div> - <div class="is-remote" v-if="user.host != null" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> - <fa icon="exclamation-triangle"/> {{ $t('@.is-remote-user') }}<a :href="user.url" rel="nofollow noopener" target="_blank">{{ $t('@.view-on-remote') }}</a> - </div> - <div class="main"> - <x-header class="header" :user="user"/> - <router-view :user="user"></router-view> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; -import parseAcct from '../../../../../../misc/acct/parse'; -import Progress from '../../../../common/scripts/loading'; -import XHeader from './user.header.vue'; - -export default Vue.extend({ - i18n: i18n(), - components: { - XHeader - }, - data() { - return { - fetching: true, - user: null - }; - }, - watch: { - $route: 'fetch' - }, - created() { - this.fetch(); - }, - methods: { - fetch() { - this.fetching = true; - Progress.start(); - this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => { - this.user = user; - this.fetching = false; - Progress.done(); - }); - }, - - warp(date) { - (this.$refs.tl as any).warp(date); - } - } -}); -</script> - -<style lang="stylus" scoped> -.omechnps - width 100% - margin 0 auto - - > .is-suspended - > .is-remote - margin-bottom 16px - padding 14px 16px - font-size 14px - - &.round - border-radius 6px - - &.shadow - box-shadow 0 3px 8px rgba(0, 0, 0, 0.2) - - &.is-suspended - color var(--suspendedInfoFg) - background var(--suspendedInfoBg) - - &.is-remote - color var(--remoteInfoFg) - background var(--remoteInfoBg) - - > a - font-weight bold - - > .main - > .header - margin-bottom 16px - -</style> diff --git a/src/client/app/desktop/views/home/user/user.header.vue b/src/client/app/desktop/views/home/user/user.header.vue deleted file mode 100644 index c8e7779678..0000000000 --- a/src/client/app/desktop/views/home/user/user.header.vue +++ /dev/null @@ -1,292 +0,0 @@ -<template> -<div class="header" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> - <div class="banner-container" :style="style"> - <div class="banner" ref="banner" :style="style"></div> - <div class="fade"></div> - <div class="title"> - <p class="name"> - <mk-user-name :user="user" :nowrap="false"/> - </p> - <div> - <span class="username"><mk-acct :user="user" :detail="true" /></span> - <span v-if="user.isBot" :title="$t('is-bot')"><fa icon="robot"/></span> - </div> - </div> - <span class="followed" v-if="$store.getters.isSignedIn && $store.state.i.id != user.id && user.isFollowed">{{ $t('follows-you') }}</span> - <div class="actions" v-if="$store.getters.isSignedIn"> - <button @click="menu" class="menu" ref="menu"><fa icon="ellipsis-h"/></button> - <mk-follow-button v-if="$store.state.i.id != user.id" :user="user" :inline="true" :transparent="false" class="follow"/> - </div> - </div> - <mk-avatar class="avatar" :user="user" :disable-preview="true"/> - <div class="body"> - <div class="description"> - <mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> - <p v-else class="empty">{{ $t('no-description') }}</p> - <x-integrations :user="user" style="margin-top:16px;"/> - </div> - <div class="fields" v-if="user.fields" :key="user.id"> - <dl class="field" v-for="(field, i) in user.fields" :key="i"> - <dt class="name"> - <mfm :text="field.name" :plain="true" :custom-emojis="user.emojis"/> - </dt> - <dd class="value"> - <mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> - </dd> - </dl> - </div> - <div class="info"> - <span class="location" v-if="user.host === null && user.location"><fa icon="map-marker"/> {{ user.location }}</span> - <span class="birthday" v-if="user.host === null && user.birthday"><fa icon="birthday-cake"/> {{ user.birthday.replace('-', $t('year')).replace('-', $t('month')) + $t('day') }} ({{ $t('years-old', { age }) }})</span> - </div> - <div class="status"> - <router-link :to="user | userPage()" class="notes-count"><b>{{ user.notesCount | number }}</b>{{ $t('posts') }}</router-link> - <router-link :to="user | userPage('following')" class="following clickable"><b>{{ user.followingCount | number }}</b>{{ $t('following') }}</router-link> - <router-link :to="user | userPage('followers')" class="followers clickable"><b>{{ user.followersCount | number }}</b>{{ $t('followers') }}</router-link> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; -import * as age from 's-age'; -import XUserMenu from '../../../../common/views/components/user-menu.vue'; -import XIntegrations from '../../../../common/views/components/integrations.vue'; - -export default Vue.extend({ - i18n: i18n('desktop/views/pages/user/user.header.vue'), - components: { - XIntegrations - }, - props: ['user'], - computed: { - style(): any { - if (this.user.bannerUrl == null) return {}; - return { - backgroundColor: this.user.bannerColor, - backgroundImage: `url(${ this.user.bannerUrl })` - }; - }, - - age(): number { - return age(this.user.birthday); - } - }, - mounted() { - if (this.user.bannerUrl) { - //window.addEventListener('load', this.onScroll); - //window.addEventListener('scroll', this.onScroll, { passive: true }); - //window.addEventListener('resize', this.onScroll); - } - }, - beforeDestroy() { - if (this.user.bannerUrl) { - //window.removeEventListener('load', this.onScroll); - //window.removeEventListener('scroll', this.onScroll); - //window.removeEventListener('resize', this.onScroll); - } - }, - methods: { - mention() { - this.$post({ mention: this.user }); - }, - onScroll() { - const banner = this.$refs.banner as any; - - const top = window.scrollY; - - const z = 1.25; // 奥行き(小さいほど奥) - const pos = -(top / z); - banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; - - const blur = top / 32 - if (blur <= 10) banner.style.filter = `blur(${blur}px)`; - }, - - menu() { - const w = this.$root.new(XUserMenu, { - source: this.$refs.menu, - user: this.user - }); - this.$once('hook:beforeDestroy', () => { - w.destroyDom(); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.header - background var(--face) - overflow hidden - - &.round - border-radius 6px - - &.shadow - box-shadow 0 3px 8px rgba(0, 0, 0, 0.2) - - > .banner-container - height 250px - overflow hidden - background-size cover - background-position center - - > .banner - height 100% - background-color #383838 - background-size cover - background-position center - box-shadow 0 0 128px rgba(0, 0, 0, 0.5) inset - - > .fade - position absolute - bottom 0 - left 0 - width 100% - height 78px - background linear-gradient(transparent, rgba(#000, 0.7)) - - > .followed - position absolute - top 12px - left 12px - padding 4px 6px - color #fff - background rgba(0, 0, 0, 0.7) - font-size 12px - - > .actions - position absolute - top 12px - right 12px - - > .menu - height 100% - padding 0 14px - color #fff - text-shadow 0 0 8px #000 - font-size 16px - - > .title - position absolute - bottom 0 - left 0 - width 100% - padding 0 0 8px 154px - color #fff - - > .name - display block - margin 0 - line-height 32px - font-weight bold - font-size 1.8em - text-shadow 0 0 8px #000 - - > div - > * - display inline-block - margin-right 16px - line-height 20px - opacity 0.8 - - &.username - font-weight bold - - > .avatar - display block - position absolute - top 170px - left 16px - z-index 2 - width 120px - height 120px - box-shadow 1px 1px 3px rgba(#000, 0.2) - - > &.cat::before, - > &.cat::after - border-width 8px - - > .body - padding 16px 16px 16px 154px - color var(--text) - - > .description - font-size 15px - - > .empty - margin 0 - opacity 0.5 - - > .fields - margin-top 16px - - > .field - display flex - padding 0 - margin 0 - align-items center - - > .name - border-right solid 1px var(--faceDivider) - padding 4px - margin 4px - width 30% - overflow hidden - white-space nowrap - text-overflow ellipsis - font-weight bold - text-align center - - > .value - padding 4px - margin 4px - width 70% - overflow hidden - white-space nowrap - text-overflow ellipsis - - > .info - margin-top 16px - padding-top 16px - border-top solid 1px var(--faceDivider) - font-size 15px - - &:empty - display none - - > * - margin-right 16px - - > .status - margin-top 16px - padding-top 16px - border-top solid 1px var(--faceDivider) - font-size 80% - - > * - display inline-block - padding-right 16px - margin-right 16px - color inherit - - &:not(:last-child) - border-right solid 1px var(--faceDivider) - - &.clickable - cursor pointer - - &:hover - color var(--faceTextButtonHover) - - > b - margin-right 4px - font-size 1rem - font-weight bold - color var(--primary) - -</style> diff --git a/src/client/app/desktop/views/home/user/user.home.vue b/src/client/app/desktop/views/home/user/user.home.vue deleted file mode 100644 index c47e0a0771..0000000000 --- a/src/client/app/desktop/views/home/user/user.home.vue +++ /dev/null @@ -1,54 +0,0 @@ -<template> -<div class="lnctpgve"> - <x-page v-if="user.pinnedPage" :page="user.pinnedPage" :key="user.pinnedPage.id" :show-title="!user.pinnedPage.hideTitleWhenPinned"/> - <mk-note-detail v-for="n in user.pinnedNotes" :key="n.id" :note="n" :compact="true"/> - <!--<mk-calendar @chosen="warp" :start="new Date(user.createdAt)"/>--> - <div class="activity"> - <ui-container :body-togglable="true" - :expanded="$store.state.device.expandUsersActivity" - @toggle="expanded => $store.commit('device/set', { key: 'expandUsersActivity', value: expanded })"> - <template #header><fa icon="chart-bar"/>{{ $t('activity') }}</template> - <x-activity :user="user" :limit="35" style="padding: 16px;"/> - </ui-container> - </div> - <x-photos :user="user"/> - <x-timeline ref="tl" :user="user"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; -import XTimeline from './user.timeline.vue'; -import XPhotos from './user.photos.vue'; -import XActivity from '../../../../common/views/components/activity.vue'; -import XPage from '../../../../common/views/components/page/page.vue'; - -export default Vue.extend({ - i18n: i18n(), - components: { - XTimeline, - XPhotos, - XActivity, - XPage, - }, - props: { - user: { - type: Object, - required: true - } - }, - methods: { - warp(date) { - (this.$refs.tl as any).warp(date); - } - } -}); -</script> - -<style lang="stylus" scoped> -.lnctpgve - > * - margin-bottom 16px - -</style> diff --git a/src/client/app/desktop/views/home/user/user.photos.vue b/src/client/app/desktop/views/home/user/user.photos.vue deleted file mode 100644 index 03abcf865c..0000000000 --- a/src/client/app/desktop/views/home/user/user.photos.vue +++ /dev/null @@ -1,98 +0,0 @@ -<template> -<ui-container :body-togglable="true" - :expanded="$store.state.device.expandUsersPhotos" - @toggle="expanded => $store.commit('device/set', { key: 'expandUsersPhotos', value: expanded })"> - <template #header><fa icon="camera"/> {{ $t('title') }}</template> - - <div class="dzsuvbsrrrwobdxifudxuefculdfiaxd"> - <p class="initializing" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('loading') }}<mk-ellipsis/></p> - <div class="stream" v-if="!fetching && images.length > 0"> - <router-link v-for="image in images" class="img" - :style="`background-image: url(${image.thumbnailUrl})`" - :key="`${image.id}:${image._note.id}`" - :to="image._note | notePage" - :title="`${image.name}\n${(new Date(image.createdAt)).toLocaleString()}`" - ></router-link> - </div> - <p class="empty" v-if="!fetching && images.length == 0">{{ $t('no-photos') }}</p> - </div> -</ui-container> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; -import { getStaticImageUrl } from '../../../../common/scripts/get-static-image-url'; -import { concat } from '../../../../../../prelude/array'; - -export default Vue.extend({ - i18n: i18n('desktop/views/pages/user/user.photos.vue'), - props: ['user'], - data() { - return { - images: [], - fetching: true - }; - }, - mounted() { - const image = [ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/apng', - 'image/vnd.mozilla.apng', - ]; - - this.$root.api('users/notes', { - userId: this.user.id, - fileType: image, - excludeNsfw: !this.$store.state.device.alwaysShowNsfw, - limit: 9, - }).then(notes => { - for (const note of notes) { - for (const file of note.files) { - file._note = note; - } - } - const files = concat(notes.map((n: any): any[] => n.files)); - this.images = files.filter(f => image.includes(f.type)).slice(0, 9); - this.fetching = false; - }); - }, - methods: { - thumbnail(image: any): string { - return this.$store.state.device.disableShowingAnimatedImages - ? getStaticImageUrl(image.thumbnailUrl) - : image.thumbnailUrl; - }, - }, -}); -</script> - -<style lang="stylus" scoped> -.dzsuvbsrrrwobdxifudxuefculdfiaxd - > .stream - display grid - grid-template-columns 1fr 1fr 1fr - gap 8px - padding 16px - background var(--face) - - > * - height 120px - background-position center center - background-size cover - background-clip content-box - border-radius 4px - - > .initializing - > .empty - margin 0 - padding 16px - text-align center - color var(--text) - - > i - margin-right 4px - -</style> diff --git a/src/client/app/desktop/views/home/user/user.timeline.vue b/src/client/app/desktop/views/home/user/user.timeline.vue deleted file mode 100644 index 2a97f2c96e..0000000000 --- a/src/client/app/desktop/views/home/user/user.timeline.vue +++ /dev/null @@ -1,113 +0,0 @@ -<template> -<div> - <mk-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"> - <template #header> - <header class="kugajpep"> - <span :data-active="mode == 'default'" @click="mode = 'default'"><fa :icon="['far', 'comment-alt']"/> {{ $t('default') }}</span> - <span :data-active="mode == 'with-replies'" @click="mode = 'with-replies'"><fa icon="comments"/> {{ $t('with-replies') }}</span> - <span :data-active="mode == 'with-media'" @click="mode = 'with-media'"><fa :icon="['far', 'images']"/> {{ $t('with-media') }}</span> - <span :data-active="mode == 'my-posts'" @click="mode = 'my-posts'"><fa icon="user"/> {{ $t('my-posts') }}</span> - </header> - </template> - </mk-notes> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; - -export default Vue.extend({ - i18n: i18n('desktop/views/pages/user/user.timeline.vue'), - - props: ['user'], - - data() { - return { - fetching: true, - mode: 'default', - unreadCount: 0, - date: null, - pagination: { - endpoint: 'users/notes', - limit: 10, - params: init => ({ - userId: this.user.id, - untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), - includeReplies: this.mode == 'with-replies', - includeMyRenotes: this.mode != 'my-posts', - withFiles: this.mode == 'with-media', - }) - } - }; - }, - - watch: { - mode() { - (this.$refs.timeline as any).reload(); - } - }, - - mounted() { - document.addEventListener('keydown', this.onDocumentKeydown); - this.$root.$on('warp', this.warp); - this.$once('hook:beforeDestroy', () => { - this.$root.$off('warp', this.warp); - document.removeEventListener('keydown', this.onDocumentKeydown); - }); - }, - - methods: { - onDocumentKeydown(e) { - if (e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') { - if (e.which == 84) { // [t] - (this.$refs.timeline as any).focus(); - } - } - }, - - warp(date) { - this.date = date; - (this.$refs.timeline as any).reload(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.kugajpep - padding 0 8px - z-index 10 - background var(--faceHeader) - box-shadow 0 1px var(--desktopTimelineHeaderShadow) - - > span - display inline-block - padding 0 10px - line-height 42px - font-size 12px - user-select none - - &[data-active] - color var(--primary) - cursor default - font-weight bold - - &:before - content "" - display block - position absolute - bottom 0 - left -8px - width calc(100% + 16px) - height 2px - background var(--primary) - - &:not([data-active]) - color var(--desktopTimelineSrc) - cursor pointer - - &:hover - color var(--desktopTimelineSrcHover) - -</style> diff --git a/src/client/app/desktop/views/pages/drive.vue b/src/client/app/desktop/views/pages/drive.vue deleted file mode 100644 index b389392ec8..0000000000 --- a/src/client/app/desktop/views/pages/drive.vue +++ /dev/null @@ -1,57 +0,0 @@ -<template> -<div class="mk-drive-page"> - <x-drive :init-folder="folder" @move-root="onMoveRoot" @open-folder="onOpenFolder"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('desktop/views/pages/drive.vue'), - components: { - XDrive: () => import('../components/drive.vue').then(m => m.default), - }, - data() { - return { - folder: null - }; - }, - created() { - this.folder = this.$route.params.folder; - }, - mounted() { - document.title = this.$t('title'); - }, - methods: { - onMoveRoot() { - const title = this.$t('title'); - - // Rewrite URL - history.pushState(null, title, '/i/drive'); - - document.title = title; - }, - onOpenFolder(folder) { - const title = `${folder.name} | ${this.$t('title')}`; - - // Rewrite URL - history.pushState(null, title, `/i/drive/folder/${folder.id}`); - - document.title = title; - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-drive-page - position fixed - width 100% - height 100% - background #fff - - > .mk-drive - height 100% -</style> diff --git a/src/client/app/desktop/views/pages/games/reversi.vue b/src/client/app/desktop/views/pages/games/reversi.vue deleted file mode 100644 index b859b95d7f..0000000000 --- a/src/client/app/desktop/views/pages/games/reversi.vue +++ /dev/null @@ -1,30 +0,0 @@ -<template> -<component :is="ui ? 'mk-ui' : 'div'"> - <x-reversi :game-id="$route.params.game" @nav="nav" :self-nav="false"/> -</component> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - components: { - XReversi: () => import('../../../../common/views/components/games/reversi/reversi.vue').then(m => m.default) - }, - props: { - ui: { - default: false - } - }, - methods: { - nav(game, actualNav) { - if (actualNav) { - this.$router.push(`/games/reversi/${game.id}`); - } else { - // TODO: https://github.com/vuejs/vue-router/issues/703 - this.$router.push(`/games/reversi/${game.id}`); - } - } - } -}); -</script> diff --git a/src/client/app/desktop/views/pages/messaging-room.vue b/src/client/app/desktop/views/pages/messaging-room.vue deleted file mode 100644 index c725074b7d..0000000000 --- a/src/client/app/desktop/views/pages/messaging-room.vue +++ /dev/null @@ -1,82 +0,0 @@ -<template> -<div class="mk-messaging-room-page"> - <x-messaging-room v-if="user || group" :user="user" :group="group" :is-naked="true"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import Progress from '../../../common/scripts/loading'; -import parseAcct from '../../../../../misc/acct/parse'; -import getUserName from '../../../../../misc/get-user-name'; - -export default Vue.extend({ - i18n: i18n(), - components: { - XMessagingRoom: () => import('../../../common/views/components/messaging-room.vue').then(m => m.default) - }, - data() { - return { - fetching: true, - user: null, - group: null - }; - }, - watch: { - $route: 'fetch' - }, - created() { - const applyBg = v => - document.documentElement.style.setProperty('background', v ? '#191b22' : '#fff', 'important'); - - applyBg(this.$store.state.device.darkmode); - - this.unwatchDarkmode = this.$store.watch(s => { - return s.device.darkmode; - }, applyBg); - - this.fetch(); - }, - beforeDestroy() { - document.documentElement.style.removeProperty('background'); - document.documentElement.style.removeProperty('background-color'); // for safari's bug - this.unwatchDarkmode(); - }, - methods: { - fetch() { - Progress.start(); - this.fetching = true; - - if (this.$route.params.user) { - this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => { - this.user = user; - this.fetching = false; - - document.title = this.$t('@.messaging') + ': ' + getUserName(this.user); - - Progress.done(); - }); - } else { - this.$root.api('users/groups/show', { groupId: this.$route.params.group }).then(group => { - this.group = group; - this.fetching = false; - - document.title = this.$t('@.messaging') + ': ' + this.group.name; - - Progress.done(); - }); - } - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-messaging-room-page - display flex - flex 1 - flex-direction column - min-height 100% - -</style> diff --git a/src/client/app/desktop/views/pages/selectdrive.vue b/src/client/app/desktop/views/pages/selectdrive.vue deleted file mode 100644 index 7e6a8e1937..0000000000 --- a/src/client/app/desktop/views/pages/selectdrive.vue +++ /dev/null @@ -1,182 +0,0 @@ -<template> -<div class="mkp-selectdrive"> - <x-drive ref="browser" - :multiple="multiple" - @selected="onSelected" - @change-selection="onChangeSelection" - /> - <footer> - <button class="upload" :title="$t('upload')" @click="upload"><fa icon="upload"/></button> - <button class="cancel" @click="close">{{ $t('cancel') }}</button> - <button class="ok" @click="ok">{{ $t('ok') }}</button> - </footer> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('desktop/views/pages/selectdrive.vue'), - components: { - XDrive: () => import('../components/drive.vue').then(m => m.default), - }, - data() { - return { - files: [] - }; - }, - computed: { - multiple(): boolean { - const q = (new URL(location.toString())).searchParams; - return q.get('multiple') == 'true'; - } - }, - mounted() { - document.title = this.$t('title'); - }, - methods: { - onSelected(file) { - this.files = [file]; - this.ok(); - }, - onChangeSelection(files) { - this.files = files; - }, - upload() { - (this.$refs.browser as any).selectLocalFile(); - }, - close() { - window.close(); - }, - ok() { - window.opener.cb(this.multiple ? this.files : this.files[0]); - this.close(); - } - } -}); -</script> - -<style lang="stylus" scoped> - - -.mkp-selectdrive - display block - position fixed - width 100% - height 100% - background #fff - - > .mk-drive - height calc(100% - 72px) - - > footer - position fixed - bottom 0 - left 0 - width 100% - height 72px - background var(--primaryLighten95) - - .upload - display inline-block - position absolute - top 8px - left 16px - cursor pointer - padding 0 - margin 8px 4px 0 0 - width 40px - height 40px - font-size 1em - color var(--primaryAlpha05) - background transparent - outline none - border solid 1px transparent - border-radius 4px - - &:hover - background transparent - border-color var(--primaryAlpha03) - - &:active - color var(--primaryAlpha06) - background transparent - border-color var(--primaryAlpha05) - //box-shadow 0 2px 4px rgba(var(--primaryDarken50), 0.15) inset - - &:focus - &:after - content "" - pointer-events none - position absolute - top -5px - right -5px - bottom -5px - left -5px - border 2px solid var(--primaryAlpha03) - border-radius 8px - - .ok - .cancel - display block - position absolute - bottom 16px - cursor pointer - padding 0 - margin 0 - width 120px - height 40px - font-size 1em - outline none - border-radius 4px - - &:focus - &:after - content "" - pointer-events none - position absolute - top -5px - right -5px - bottom -5px - left -5px - border 2px solid var(--primaryAlpha03) - border-radius 8px - - &:disabled - opacity 0.7 - cursor default - - .ok - right 16px - color var(--primaryForeground) - background linear-gradient(to bottom, var(--primaryLighten25) 0%, var(--primaryLighten10) 100%) - border solid 1px var(--primaryLighten15) - - &:not(:disabled) - font-weight bold - - &:hover:not(:disabled) - background linear-gradient(to bottom, var(--primaryLighten8) 0%, var(--primaryDarken8) 100%) - border-color var(--primary) - - &:active:not(:disabled) - background var(--primary) - border-color var(--primary) - - .cancel - right 148px - color #888 - background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) - border solid 1px #e2e2e2 - - &:hover - background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) - border-color #dcdcdc - - &:active - background #ececec - border-color #dcdcdc - -</style> diff --git a/src/client/app/desktop/views/pages/settings.vue b/src/client/app/desktop/views/pages/settings.vue deleted file mode 100644 index 826fae2529..0000000000 --- a/src/client/app/desktop/views/pages/settings.vue +++ /dev/null @@ -1,27 +0,0 @@ -<template> -<mk-ui> - <main> - <x-settings :in-window="false" :page="$route.params.page" /> - </main> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - components: { - XSettings: () => import('../components/settings.vue').then(m => m.default) - }, - mounted() { - document.title = this.$root.instanceName; - }, -}); -</script> - -<style lang="stylus" scoped> -main - margin 0 auto - max-width 900px - -</style> diff --git a/src/client/app/desktop/views/pages/welcome.vue b/src/client/app/desktop/views/pages/welcome.vue deleted file mode 100644 index 511e1548e5..0000000000 --- a/src/client/app/desktop/views/pages/welcome.vue +++ /dev/null @@ -1,509 +0,0 @@ -<template> -<div class="mk-welcome"> - <div class="banner" :style="{ backgroundImage: banner ? `url(${banner})` : null }"></div> - - <button @click="dark"> - <template v-if="$store.state.device.darkmode"><fa icon="moon"/></template> - <template v-else><fa :icon="['far', 'moon']"/></template> - </button> - - <mk-forkit class="forkit"/> - - <main> - <div class="body"> - <div class="main block"> - <div> - <h1 v-if="name != null && name != ''">{{ name }}</h1> - <h1 v-else><img svg-inline src="../../../../assets/title.svg" alt="Misskey"></h1> - - <div class="info"> - <span><b>{{ host }}</b> - <span v-html="$t('powered-by-misskey')"></span></span> - <span class="stats" v-if="stats"> - <span><fa icon="user"/> {{ stats.originalUsersCount | number }}</span> - <span><fa icon="pencil-alt"/> {{ stats.originalNotesCount | number }}</span> - </span> - </div> - - <div class="desc"> - <span class="desc" v-html="description || $t('@.about')"></span> - <a class="about" @click="about">{{ $t('about') }}</a> - </div> - - <p class="sign"> - <span class="signup" @click="signup">{{ $t('@.signup') }}</span> - <span class="divider">|</span> - <span class="signin" @click="signin">{{ $t('@.signin') }}</span> - </p> - - <img v-if="meta" :src="meta.mascotImageUrl" alt="" title="藍" class="char"> - </div> - </div> - - <div class="announcements block"> - <header><fa icon="broadcast-tower"/> {{ $t('announcements') }}</header> - <div v-if="announcements && announcements.length > 0"> - <div v-for="announcement in announcements"> - <h1 v-html="announcement.title"></h1> - <mfm :text="announcement.text"/> - <img v-if="announcement.image" :src="announcement.image" alt="" style="display: block; max-height: 130px; max-width: 100%;"/> - </div> - </div> - </div> - - <div class="photos block"> - <header><fa :icon="['far', 'images']"/> {{ $t('photos') }}</header> - <div> - <div v-for="photo in photos" :style="`background-image: url(${photo.thumbnailUrl})`"></div> - </div> - </div> - - <div class="tag-cloud block"> - <div> - <mk-tag-cloud/> - </div> - </div> - - <div class="nav block"> - <div> - <mk-nav class="nav"/> - </div> - </div> - - <div class="side"> - <div class="trends block"> - <div> - <mk-trends/> - </div> - </div> - - <div class="tl block"> - <header><fa :icon="['far', 'comment-alt']"/> {{ $t('timeline') }}</header> - <div> - <mk-welcome-timeline class="tl" :max="20"/> - </div> - </div> - - <div class="info block"> - <header><fa icon="info-circle"/> {{ $t('info') }}</header> - <div> - <div v-if="meta" class="body"> - <p>Version: <b>{{ meta.version }}</b></p> - <p>Maintainer: <b><a :href="'mailto:' + meta.maintainerEmail" target="_blank">{{ meta.maintainerName }}</a></b></p> - </div> - </div> - </div> - </div> - </div> - </main> - - <modal name="about" class="about modal" width="800px" height="auto" scrollable> - <article class="fpdezooorhntlzyeszemrsqdlgbysvxq"> - <h1>{{ $t('@.intro.title') }}</h1> - <p v-html="this.$t('@.intro.about')"></p> - <section> - <h2>{{ $t('@.intro.features') }}</h2> - <section> - <div class="body"> - <h3>{{ $t('@.intro.rich-contents') }}</h3> - <p v-html="this.$t('@.intro.rich-contents-desc')"></p> - </div> - <div class="image"><img src="/assets/about/post.png" alt=""></div> - </section> - <section> - <div class="body"> - <h3>{{ $t('@.intro.reaction') }}</h3> - <p v-html="this.$t('@.intro.reaction-desc')"></p> - </div> - <div class="image"><img src="/assets/about/reaction.png" alt=""></div> - </section> - <section> - <div class="body"> - <h3>{{ $t('@.intro.ui') }}</h3> - <p v-html="this.$t('@.intro.ui-desc')"></p> - </div> - <div class="image"><img src="/assets/about/ui.png" alt=""></div> - </section> - <section> - <div class="body"> - <h3>{{ $t('@.intro.drive') }}</h3> - <p v-html="this.$t('@.intro.drive-desc')"></p> - </div> - <div class="image"><img src="/assets/about/drive.png" alt=""></div> - </section> - </section> - <p v-html="this.$t('@.intro.outro')"></p> - </article> - </modal> - - <modal name="signup" class="modal" width="450px" height="auto" scrollable> - <header class="formHeader">{{ $t('@.signup') }}</header> - <mk-signup class="form"/> - </modal> - - <modal name="signin" class="modal" width="450px" height="auto" scrollable> - <header class="formHeader">{{ $t('@.signin') }}</header> - <mk-signin class="form"/> - </modal> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { host, copyright } from '../../../config'; -import { concat } from '../../../../../prelude/array'; -import { toUnicode } from 'punycode'; - -export default Vue.extend({ - i18n: i18n('desktop/views/pages/welcome.vue'), - data() { - return { - meta: null, - stats: null, - banner: null, - copyright, - host: toUnicode(host), - name: null, - description: '', - announcements: [], - photos: [] - }; - }, - - created() { - this.$root.getMeta().then(meta => { - this.meta = meta; - this.name = meta.name; - this.description = meta.description; - this.announcements = meta.announcements; - this.banner = meta.bannerUrl; - }); - - this.$root.api('stats').then(stats => { - this.stats = stats; - }); - - const image = [ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/apng', - 'image/vnd.mozilla.apng', - ]; - - this.$root.api('notes/local-timeline', { - fileType: image, - excludeNsfw: true, - limit: 6 - }).then((notes: any[]) => { - const files = concat(notes.map((n: any): any[] => n.files)); - this.photos = files.filter(f => image.includes(f.type)).slice(0, 6); - }); - }, - - methods: { - about() { - this.$modal.show('about'); - }, - - signup() { - this.$modal.show('signup'); - }, - - signin() { - this.$modal.show('signin'); - }, - - dark() { - this.$store.commit('device/set', { - key: 'darkmode', - value: !this.$store.state.device.darkmode - }); - } - } -}); -</script> - -<style lang="stylus"> -#wait - right auto - left 15px - -.v--modal-overlay - background rgba(0, 0, 0, 0.6) - -.modal - .form - padding 24px 48px 48px 48px - - .formHeader - text-align center - padding 48px 0 12px 0 - margin 0 48px - font-size 1.5em - - .v--modal-box - background var(--face) - color var(--text) - - .formHeader - border-bottom solid 1px rgba(#000, 0.2) - -.v--modal-overlay.about - .v--modal-box.v--modal - margin 32px 0 - -.fpdezooorhntlzyeszemrsqdlgbysvxq - padding 64px - - > p:last-child - margin-bottom 0 - - > h1 - margin-top 0 - - > section - > h2 - border-bottom 1px solid var(--faceDivider) - - > section - display grid - grid-template-rows 1fr - grid-template-columns 180px 1fr - gap 32px - margin-bottom 32px - padding-bottom 32px - border-bottom 1px solid var(--faceDivider) - - &:nth-child(odd) - grid-template-columns 1fr 180px - - > .body - grid-column 1 - - > .image - grid-column 2 - - > .body - grid-row 1 - grid-column 2 - - > .image - grid-row 1 - grid-column 1 - - > img - display block - width 100% - height 100% - object-fit cover -</style> - -<style lang="stylus" scoped> -.mk-welcome - display flex - min-height 100vh - - > .banner - position absolute - top 0 - left 0 - width 100% - height 400px - background-position center - background-size cover - opacity 0.7 - - &:after - content "" - display block - position absolute - bottom 0 - left 0 - width 100% - height 100px - background linear-gradient(transparent, var(--bg)) - - > .forkit - position absolute - top 0 - right 0 - - > button - position fixed - z-index 1 - bottom 16px - left 16px - padding 16px - font-size 18px - color var(--text) - - > main - margin 0 auto - padding 64px - width 100% - max-width 1200px - - .block - color var(--text) - background var(--face) - overflow auto - - > header - z-index 1 - padding 0 16px - line-height 48px - background var(--faceHeader) - box-shadow 0 1px 0 rgba(0, 0, 0, 0.1) - - & + div - max-height calc(100% - 48px) - - > div - overflow auto - - > .body - display grid - grid-template-rows 390px 1fr 256px 64px - grid-template-columns 1fr 1fr 350px - gap 16px - height 1150px - - > .main - grid-row 1 - grid-column 1 / 3 - - > div - padding 32px - min-height 100% - - > h1 - margin 0 - - > svg - margin -8px 0 0 -16px - width 280px - height 100px - fill currentColor - - > .info - margin 0 auto 16px auto - width $width - font-size 14px - - > .stats - margin-left 16px - padding-left 16px - border-left solid 1px var(--faceDivider) - - > * - margin-right 16px - - > .desc - max-width calc(100% - 150px) - - > .sign - font-size 120% - margin-bottom 0 - - > .divider - margin 0 16px - - > .signin - > .signup - cursor pointer - - &:hover - color var(--primary) - - > .char - display block - position absolute - right 16px - bottom 0 - height 320px - opacity 0.7 - - > *:not(.char) - z-index 1 - - > .announcements - grid-row 2 - grid-column 1 - - > div - padding 32px - - > div - padding 0 0 16px 0 - margin 0 0 16px 0 - border-bottom 1px solid var(--faceDivider) - - > h1 - margin 0 - font-size 1.25em - - > .photos - grid-row 2 - grid-column 2 - - > div - display grid - grid-template-rows 1fr 1fr 1fr - grid-template-columns 1fr 1fr - gap 8px - height 100% - padding 16px - - > div - //border-radius 4px - background-position center center - background-size cover - - > .tag-cloud - grid-row 3 - grid-column 1 / 3 - - > div - height 256px - padding 32px - - > .nav - display flex - justify-content center - align-items center - grid-row 4 - grid-column 1 / 3 - font-size 14px - - > .side - display grid - grid-row 1 / 5 - grid-column 3 - grid-template-rows 1fr 350px - grid-template-columns 1fr - gap 16px - - > .tl - grid-row 1 - grid-column 1 - overflow auto - - > .trends - grid-row 2 - grid-column 1 - padding 8px - - > .info - grid-row 3 - grid-column 1 - - > div - padding 16px - - > .body - > p - display block - margin 0 - -</style> diff --git a/src/client/app/desktop/views/widgets/activity.vue b/src/client/app/desktop/views/widgets/activity.vue deleted file mode 100644 index 73c6d0ef64..0000000000 --- a/src/client/app/desktop/views/widgets/activity.vue +++ /dev/null @@ -1,33 +0,0 @@ -<template> -<mk-activity - :design="props.design" - :init-view="props.view" - :user="$store.state.i" - @view-changed="viewChanged"/> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -export default define({ - name: 'activity', - props: () => ({ - design: 0, - view: 0 - }) -}).extend({ - methods: { - func() { - if (this.props.design == 2) { - this.props.design = 0; - } else { - this.props.design++; - } - this.save(); - }, - viewChanged(view) { - this.props.view = view; - this.save(); - } - } -}); -</script> diff --git a/src/client/app/desktop/views/widgets/customize.vue b/src/client/app/desktop/views/widgets/customize.vue deleted file mode 100644 index eb71910382..0000000000 --- a/src/client/app/desktop/views/widgets/customize.vue +++ /dev/null @@ -1,21 +0,0 @@ -<template> -<div class="mkw-customize"> - <ui-button @click="customize()">{{ $t('@.customize-home') }}</ui-button> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; - -export default define({ - name: 'customize', -}).extend({ - i18n: i18n(), - methods: { - customize(date) { - location.href = '/?customize'; - } - } -}); -</script> diff --git a/src/client/app/desktop/views/widgets/index.ts b/src/client/app/desktop/views/widgets/index.ts deleted file mode 100644 index c00cd2ff4d..0000000000 --- a/src/client/app/desktop/views/widgets/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import Vue from 'vue'; - -import wNotifications from './notifications.vue'; -import wTimemachine from './timemachine.vue'; -import wActivity from './activity.vue'; -import wTrends from './trends.vue'; -import wUsers from './users.vue'; -import wPolls from './polls.vue'; -import wMessaging from './messaging.vue'; -import wProfile from './profile.vue'; -import wCustomize from './customize.vue'; - -Vue.component('mkw-notifications', wNotifications); -Vue.component('mkw-timemachine', wTimemachine); -Vue.component('mkw-activity', wActivity); -Vue.component('mkw-trends', wTrends); -Vue.component('mkw-users', wUsers); -Vue.component('mkw-polls', wPolls); -Vue.component('mkw-messaging', wMessaging); -Vue.component('mkw-profile', wProfile); -Vue.component('mkw-customize', wCustomize); diff --git a/src/client/app/desktop/views/widgets/messaging.vue b/src/client/app/desktop/views/widgets/messaging.vue deleted file mode 100644 index e94e745c19..0000000000 --- a/src/client/app/desktop/views/widgets/messaging.vue +++ /dev/null @@ -1,60 +0,0 @@ -<template> -<div class="mkw-messaging"> - <ui-container :show-header="props.design == 0"> - <template #header><fa icon="comments"/>{{ $t('@.messaging') }}</template> - <template #func><button @click="add"><fa icon="plus"/></button></template> - - <x-messaging ref="index" compact @navigate="navigate" @navigateGroup="navigateGroup"/> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; -import MkMessagingRoomWindow from '../components/messaging-room-window.vue'; -import MkMessagingWindow from '../components/messaging-window.vue'; - -export default define({ - name: 'messaging', - props: () => ({ - design: 0 - }) -}).extend({ - i18n: i18n(''), - components: { - XMessaging: () => import('../../../common/views/components/messaging.vue').then(m => m.default) - }, - methods: { - navigate(user) { - this.$root.new(MkMessagingRoomWindow, { - user: user - }); - }, - navigateGroup(group) { - this.$root.new(MkMessagingRoomWindow, { - group: group - }); - }, - add() { - this.$root.new(MkMessagingWindow); - }, - func() { - if (this.props.design == 1) { - this.props.design = 0; - } else { - this.props.design++; - } - this.save(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mkw-messaging - .mk-messaging - max-height 250px - overflow auto - -</style> diff --git a/src/client/app/desktop/views/widgets/notifications.vue b/src/client/app/desktop/views/widgets/notifications.vue deleted file mode 100644 index 5a84f7b371..0000000000 --- a/src/client/app/desktop/views/widgets/notifications.vue +++ /dev/null @@ -1,55 +0,0 @@ -<template> -<div class="mkw-notifications"> - <ui-container :show-header="!props.compact"> - <template #header><fa :icon="['far', 'bell']"/>{{ props.type === 'all' ? $t('title') : $t('@.notification-types.' + props.type) }}</template> - <template #func><button :title="$t('@.notification-type')" @click="settings"><fa icon="cog"/></button></template> - - <mk-notifications :class="$style.notifications" :type="props.type === 'all' ? null : props.type"/> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; - -export default define({ - name: 'notifications', - props: () => ({ - compact: false, - type: 'all' - }) -}).extend({ - i18n: i18n('desktop/views/widgets/notifications.vue'), - methods: { - settings() { - this.$root.dialog({ - title: this.$t('@.notification-type'), - type: null, - select: { - items: ['all', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest'].map(x => ({ - value: x, text: this.$t('@.notification-types.' + x) - })) - default: this.props.type, - }, - showCancelButton: true - }).then(({ canceled, result: type }) => { - if (canceled) return; - this.props.type = type; - this.save(); - }); - }, - func() { - this.props.compact = !this.props.compact; - this.save(); - } - } -}); -</script> - -<style lang="stylus" module> -.notifications - max-height 300px - overflow auto - -</style> diff --git a/src/client/app/desktop/views/widgets/polls.vue b/src/client/app/desktop/views/widgets/polls.vue deleted file mode 100644 index c77762ecdf..0000000000 --- a/src/client/app/desktop/views/widgets/polls.vue +++ /dev/null @@ -1,110 +0,0 @@ -<template> -<div class="mkw-polls"> - <ui-container :show-header="!props.compact"> - <template #header><fa icon="chart-pie"/>{{ $t('title') }}</template> - <template #func> - <button :title="$t('title')" @click="fetch"> - <fa v-if="!fetching && more" icon="arrow-right"/> - <fa v-if="!fetching && !more" icon="sync"/> - </button> - </template> - - <div class="mkw-polls--body"> - <div class="poll" v-if="!fetching && poll != null"> - <p v-if="poll.text"><router-link :to="poll | notePage"> - <mfm :text="poll.text" :author="poll.user" :custom-emojis="poll.emojis"/> - </router-link></p> - <p v-if="!poll.text"><router-link :to="poll | notePage"><fa icon="link"/></router-link></p> - <mk-poll :note="poll"/> - </div> - <p class="empty" v-if="!fetching && poll == null">{{ $t('nothing') }}</p> - <p class="fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; - -export default define({ - name: 'polls', - props: () => ({ - compact: false - }) -}).extend({ - i18n: i18n('desktop/views/widgets/polls.vue'), - data() { - return { - poll: null, - fetching: true, - more: true, - offset: 0 - }; - }, - mounted() { - this.fetch(); - }, - methods: { - func() { - this.props.compact = !this.props.compact; - this.save(); - }, - fetch() { - this.fetching = true; - this.poll = null; - - this.$root.api('notes/polls/recommendation', { - limit: 1, - offset: this.offset - }).then(notes => { - const poll = notes ? notes[0] : null; - if (poll == null) { - this.more = false; - this.offset = 0; - } else { - this.more = true; - this.offset++; - } - this.poll = poll; - this.fetching = false; - }).catch(() => { - this.poll = null; - this.fetching = false; - this.more = false; - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mkw-polls--body - > .poll - padding 16px - font-size 12px - color var(--text) - - > p - margin 0 0 8px 0 - - > a - color inherit - - > .empty - margin 0 - padding 16px - text-align center - color var(--text) - - > .fetching - margin 0 - padding 16px - text-align center - color var(--text) - - > [data-icon] - margin-right 4px - -</style> diff --git a/src/client/app/desktop/views/widgets/profile.vue b/src/client/app/desktop/views/widgets/profile.vue deleted file mode 100644 index bad1925f69..0000000000 --- a/src/client/app/desktop/views/widgets/profile.vue +++ /dev/null @@ -1,132 +0,0 @@ -<template> -<div class="egwyvoaaryotefqhqtmiyawwefemjfsd"> - <ui-container :show-header="false" :naked="props.design == 2"> - <div class="egwyvoaaryotefqhqtmiyawwefemjfsd-body" - :data-compact="props.design == 1 || props.design == 2" - :data-melt="props.design == 2" - > - <div class="banner" - :style="$store.state.i.bannerUrl ? `background-image: url(${$store.state.i.bannerUrl})` : ''" - :title="$t('update-banner')" - @click="updateBanner()" - ></div> - <mk-avatar class="avatar" :user="$store.state.i" - :disable-link="true" - @click="updateAvatar()" - :title="$t('update-avatar')" - /> - <router-link class="name" :to="$store.state.i | userPage"><mk-user-name :user="$store.state.i"/></router-link> - <p class="username">@{{ $store.state.i | acct }}</p> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; -import updateAvatar from '../../api/update-avatar'; -import updateBanner from '../../api/update-banner'; - -export default define({ - name: 'profile', - props: () => ({ - design: 0 - }) -}).extend({ - i18n: i18n('desktop/views/widgets/profile.vue'), - methods: { - func() { - if (this.props.design == 2) { - this.props.design = 0; - } else { - this.props.design++; - } - this.save(); - }, - updateAvatar() { - updateAvatar(this.$root)(); - }, - updateBanner() { - updateBanner(this.$root)(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.egwyvoaaryotefqhqtmiyawwefemjfsd-body - &[data-compact] - > .banner:before - content "" - display block - width 100% - height 100% - background rgba(#000, 0.5) - - > .avatar - top ((100px - 58px) / 2) - left ((100px - 58px) / 2) - border none - border-radius 100% - box-shadow 0 0 16px rgba(#000, 0.5) - - > .name - position absolute - top 0 - left 92px - margin 0 - line-height 100px - color #fff - text-shadow 0 0 8px rgba(#000, 0.5) - - > .username - display none - - &[data-melt] - > .banner - visibility hidden - - > .avatar - box-shadow none - - > .name - color #666 - text-shadow none - - > .banner - height 100px - background-color var(--primaryAlpha01) - background-size cover - background-position center - cursor pointer - - > .avatar - display block - position absolute - top 76px - left 16px - width 58px - height 58px - border solid 3px var(--face) - border-radius 8px - cursor pointer - - > .name - display block - margin 10px 0 0 84px - line-height 16px - font-weight bold - color var(--text) - overflow hidden - text-overflow ellipsis - - > .username - display block - margin 4px 0 8px 84px - line-height 16px - font-size 0.9em - color var(--text) - opacity 0.7 - -</style> diff --git a/src/client/app/desktop/views/widgets/timemachine.vue b/src/client/app/desktop/views/widgets/timemachine.vue deleted file mode 100644 index 854b01c13e..0000000000 --- a/src/client/app/desktop/views/widgets/timemachine.vue +++ /dev/null @@ -1,29 +0,0 @@ -<template> -<div class="mkw-timemachine"> - <mk-calendar :design="props.design" @chosen="chosen"/> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -export default define({ - name: 'timemachine', - props: () => ({ - design: 0 - }) -}).extend({ - methods: { - chosen(date) { - this.$root.$emit('warp', date); - }, - func() { - if (this.props.design == 5) { - this.props.design = 0; - } else { - this.props.design++; - } - this.save(); - } - } -}); -</script> diff --git a/src/client/app/desktop/views/widgets/trends.vue b/src/client/app/desktop/views/widgets/trends.vue deleted file mode 100644 index c512945895..0000000000 --- a/src/client/app/desktop/views/widgets/trends.vue +++ /dev/null @@ -1,103 +0,0 @@ -<template> -<div class="mkw-trends"> - <ui-container :show-header="!props.compact"> - <template #header><fa icon="fire"/>{{ $t('title') }}</template> - <template #func><button :title="$t('title')" @click="fetch"><fa icon="sync"/></button></template> - - <div class="mkw-trends--body"> - <p class="fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p> - <div class="note" v-else-if="note != null"> - <p class="text"><router-link :to="note | notePage">{{ note.text }}</router-link></p> - <p class="author">―<router-link :to="note.user | userPage">@{{ note.user | acct }}</router-link></p> - </div> - <p class="empty" v-else>{{ $t('nothing') }}</p> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; - -export default define({ - name: 'trends', - props: () => ({ - compact: false - }) -}).extend({ - i18n: i18n('desktop/views/widgets/trends.vue'), - data() { - return { - note: null, - fetching: true, - offset: 0 - }; - }, - mounted() { - this.fetch(); - }, - methods: { - func() { - this.props.compact = !this.props.compact; - this.save(); - }, - fetch() { - this.fetching = true; - this.note = null; - - this.$root.api('notes/trend', { - limit: 1, - offset: this.offset, - renote: false, - reply: false, - file: false, - poll: false - }).then(notes => { - const note = notes ? notes[0] : null; - if (note == null) { - this.offset = 0; - } else { - this.offset++; - } - this.note = note; - this.fetching = false; - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mkw-trends - .mkw-trends--body - > .note - padding 16px - font-size 12px - font-style oblique - color #555 - - > p - margin 0 - - > .text, - > .author - > a - color inherit - - > .empty - margin 0 - padding 16px - text-align center - color var(--text) - - > .fetching - margin 0 - padding 16px - text-align center - color var(--text) - - > [data-icon] - margin-right 4px - -</style> diff --git a/src/client/app/desktop/views/widgets/users.vue b/src/client/app/desktop/views/widgets/users.vue deleted file mode 100644 index 85902fc20c..0000000000 --- a/src/client/app/desktop/views/widgets/users.vue +++ /dev/null @@ -1,145 +0,0 @@ -<template> -<div class="mkw-users"> - <ui-container :show-header="!props.compact"> - <template #header><fa icon="users"/>{{ $t('title') }}</template> - <template #func> - <button :title="$t('title')" @click="refresh"> - <fa v-if="!fetching && more" icon="arrow-right"/> - <fa v-if="!fetching && !more" icon="sync"/> - </button> - </template> - - <div class="mkw-users--body"> - <p class="fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p> - <template v-else-if="users.length != 0"> - <div class="user" v-for="_user in users"> - <mk-avatar class="avatar" :user="_user"/> - <div class="body"> - <router-link class="name" :to="_user | userPage" v-user-preview="_user.id"><mk-user-name :user="_user"/></router-link> - <p class="username">@{{ _user | acct }}</p> - </div> - </div> - </template> - <p class="empty" v-else>{{ $t('no-one') }}</p> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; - -const limit = 3; - -export default define({ - name: 'users', - props: () => ({ - compact: false - }) -}).extend({ - i18n: i18n('desktop/views/widgets/users.vue'), - data() { - return { - users: [], - fetching: true, - more: true, - page: 0 - }; - }, - mounted() { - this.fetch(); - }, - methods: { - func() { - this.props.compact = !this.props.compact; - this.save(); - }, - fetch() { - this.fetching = true; - this.users = []; - - this.$root.api('users/recommendation', { - limit: limit, - offset: limit * this.page - }).then(users => { - this.users = users; - this.fetching = false; - }).catch(() => { - this.users = []; - this.fetching = false; - this.more = false; - this.page = 0; - }); - }, - refresh() { - if (this.users.length < limit) { - this.more = false; - this.page = 0; - } else { - this.more = true; - this.page++; - } - this.fetch(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mkw-users - .mkw-users--body - > .user - padding 16px - border-bottom solid 1px var(--faceDivider) - - &:last-child - border-bottom none - - &:after - content "" - display block - clear both - - > .avatar - display block - float left - margin 0 12px 0 0 - width 42px - height 42px - border-radius 8px - - > .body - float left - width calc(100% - 54px) - - > .name - margin 0 - font-size 16px - line-height 24px - color var(--text) - - > .username - display block - margin 0 - font-size 15px - line-height 16px - color var(--text) - opacity 0.7 - - > .empty - margin 0 - padding 16px - text-align center - color var(--text) - - > .fetching - margin 0 - padding 16px - text-align center - color var(--text) - - > [data-icon] - margin-right 4px - -</style> diff --git a/src/client/app/dev/script.ts b/src/client/app/dev/script.ts deleted file mode 100644 index 9adcb84d7c..0000000000 --- a/src/client/app/dev/script.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Developer Center - */ - -import Vue from 'vue'; -import VueRouter from 'vue-router'; -import BootstrapVue from 'bootstrap-vue'; -import 'bootstrap/dist/css/bootstrap.css'; -import 'bootstrap-vue/dist/bootstrap-vue.css'; - -// Style -import './style.styl'; - -import init from '../init'; - -import Index from './views/index.vue'; -import Apps from './views/apps.vue'; -import AppNew from './views/new-app.vue'; -import App from './views/app.vue'; -import ui from './views/ui.vue'; -import NotFound from '../common/views/pages/not-found.vue'; - -Vue.use(BootstrapVue); - -Vue.component('mk-ui', ui); - -/** - * init - */ -init(launch => { - // Init router - const router = new VueRouter({ - mode: 'history', - base: '/dev/', - routes: [ - { path: '/', component: Index }, - { path: '/apps', component: Apps }, - { path: '/app/new', component: AppNew }, - { path: '/app/:id', component: App }, - { path: '*', component: NotFound } - ] - }); - - // Launch the app - launch(router); -}); diff --git a/src/client/app/dev/style.styl b/src/client/app/dev/style.styl deleted file mode 100644 index e635897b17..0000000000 --- a/src/client/app/dev/style.styl +++ /dev/null @@ -1,10 +0,0 @@ -@import "../app" -@import "../reset" - -// Bootstrapのデザインを崩すので: -* - position initial - background-clip initial !important - -html - background-color #fff diff --git a/src/client/app/dev/views/app.vue b/src/client/app/dev/views/app.vue deleted file mode 100644 index 2379d71aa5..0000000000 --- a/src/client/app/dev/views/app.vue +++ /dev/null @@ -1,41 +0,0 @@ -<template> -<mk-ui> - <p v-if="fetching">{{ $t('@.loading') }}</p> - <b-card v-if="!fetching" :header="app.name"> - <b-form-group label="App Secret"> - <b-input :value="app.secret" readonly/> - </b-form-group> - </b-card> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -export default Vue.extend({ - i18n: i18n(), - data() { - return { - fetching: true, - app: null - }; - }, - watch: { - $route: 'fetch' - }, - mounted() { - this.fetch(); - }, - methods: { - fetch() { - this.fetching = true; - this.$root.api('app/show', { - appId: this.$route.params.id - }).then(app => { - this.app = app; - this.fetching = false; - }); - } - } -}); -</script> diff --git a/src/client/app/dev/views/apps.vue b/src/client/app/dev/views/apps.vue deleted file mode 100644 index b99ccdf576..0000000000 --- a/src/client/app/dev/views/apps.vue +++ /dev/null @@ -1,39 +0,0 @@ -<template> -<mk-ui> - <b-card :header="$t('manage-apps')"> - <b-button to="/app/new" variant="primary">{{ $t('create-app') }}</b-button> - <hr> - <div class="apps"> - <p v-if="fetching">{{ $t('@.loading') }}</p> - <template v-if="!fetching"> - <b-alert v-if="apps.length == 0">{{ $t('app-missing') }}</b-alert> - <b-list-group v-else> - <b-list-group-item v-for="app in apps" :key="app.id" :to="`/app/${app.id}`"> - {{ app.name }} - </b-list-group-item> - </b-list-group> - </template> - </div> - </b-card> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -export default Vue.extend({ - i18n: i18n('dev/views/apps.vue'), - data() { - return { - fetching: true, - apps: [] - }; - }, - mounted() { - this.$root.api('my/apps').then(apps => { - this.apps = apps; - this.fetching = false; - }); - } -}); -</script> diff --git a/src/client/app/dev/views/index.vue b/src/client/app/dev/views/index.vue deleted file mode 100644 index db0e4d57c2..0000000000 --- a/src/client/app/dev/views/index.vue +++ /dev/null @@ -1,13 +0,0 @@ -<template> -<mk-ui> - <b-button to="/apps" variant="primary">{{ $t('manage-apps') }}</b-button> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -export default Vue.extend({ - i18n: i18n('dev/views/index.vue') -}); -</script> diff --git a/src/client/app/dev/views/new-app.vue b/src/client/app/dev/views/new-app.vue deleted file mode 100644 index dbb41211cc..0000000000 --- a/src/client/app/dev/views/new-app.vue +++ /dev/null @@ -1,62 +0,0 @@ -<template> -<mk-ui> - <b-card :header="$t('new-app')"> - <b-alert show variant="info"><fa icon="info-circle"/> {{ $t('new-app-info') }}</b-alert> - <b-form @submit.prevent="onSubmit" autocomplete="off"> - <b-form-group :label="$t('app-name')" :description="$t('app-name-desc')"> - <b-form-input v-model="name" type="text" :placeholder="$t('app-name-placeholder')" autocomplete="off" required/> - </b-form-group> - <b-form-group :label="$t('app-overview')" :description="$t('app-overview-desc')"> - <b-textarea v-model="description" :placeholder="$t('app-overview-placeholder')" autocomplete="off" required></b-textarea> - </b-form-group> - <b-form-group :label="$t('callback-url')" :description="$t('callback-url-desc')"> - <b-input v-model="cb" type="url" :placeholder="$t('callback-url-placeholder')" autocomplete="off"/> - </b-form-group> - <b-card :header="$t('authority')"> - <b-form-group :description="$t('authority-desc')"> - <b-alert show variant="warning"><fa icon="exclamation-triangle"/> {{ $t('authority-warning') }}</b-alert> - <b-form-checkbox-group v-model="permission" stacked> - <b-form-checkbox v-for="v in permissionsList" :value="v" :key="v">{{ $t(`@.permissions.${v}`) }} ({{ v }})</b-form-checkbox> - </b-form-checkbox-group> - </b-form-group> - </b-card> - <hr> - <b-button type="submit" variant="primary">{{ $t('create-app') }}</b-button> - </b-form> - </b-card> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../i18n'; -import { kinds } from '../../../../server/api/kinds'; - -export default Vue.extend({ - i18n: i18n('dev/views/new-app.vue'), - data() { - return { - name: '', - description: '', - cb: '', - nidState: null, - permission: [], - permissionsList: kinds - }; - }, - methods: { - onSubmit() { - this.$root.api('app/create', { - name: this.name, - description: this.description, - callbackUrl: this.cb, - permission: this.permission - }).then(() => { - location.href = '/dev/apps'; - }).catch(() => { - alert(this.$t('@.dev.failed-to-create')); - }); - } - } -}); -</script> diff --git a/src/client/app/dev/views/ui.vue b/src/client/app/dev/views/ui.vue deleted file mode 100644 index f1e001909f..0000000000 --- a/src/client/app/dev/views/ui.vue +++ /dev/null @@ -1,20 +0,0 @@ -<template> -<div> - <b-navbar toggleable="md" type="dark" variant="info"> - <b-navbar-brand>Developers</b-navbar-brand> - <b-navbar-nav> - <b-nav-item to="/">Home</b-nav-item> - <b-nav-item to="/apps">Apps</b-nav-item> - </b-navbar-nav> - </b-navbar> - <main> - <slot></slot> - </main> -</div> -</template> - -<style lang="stylus" scoped> -main - padding 32px - max-width 700px -</style> diff --git a/src/client/app/i18n.ts b/src/client/app/i18n.ts deleted file mode 100644 index 2d0d9ba550..0000000000 --- a/src/client/app/i18n.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { lang, locale } from './config'; - -export default function(scope?: string) { - const texts = scope ? locale[scope] || {} : {}; - texts['@'] = locale['common']; - texts['@deck'] = locale['deck']; - return { - sync: false, - locale: lang, - messages: { - [lang]: texts - } - }; -} diff --git a/src/client/app/init.css b/src/client/app/init.css deleted file mode 100644 index db5e23c56d..0000000000 --- a/src/client/app/init.css +++ /dev/null @@ -1,57 +0,0 @@ -@charset "utf-8"; - -/** - * Boot screen style - */ - -html { - font-family: Roboto, HelveticaNeue, Arial, sans-serif; -} - -body > noscript { - position: fixed; - z-index: 2; - top: 0; - left: 0; - width: 100%; - height: 100%; - text-align: center; - background: #fff; -} - body > noscript > p { - display: block; - margin: 32px; - font-size: 2em; - color: #555; - } - -#ini { - position: fixed; - z-index: 1; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: var(--bg); - cursor: wait; -} - #ini > svg { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - margin: auto; - width: 64px; - height: 64px; - animation: ini 0.6s infinite linear; - } - -@keyframes ini { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/src/client/app/init.ts b/src/client/app/init.ts deleted file mode 100644 index 6fd11b9b75..0000000000 --- a/src/client/app/init.ts +++ /dev/null @@ -1,512 +0,0 @@ -/** - * App initializer - */ - -import Vue from 'vue'; -import Vuex from 'vuex'; -import VueRouter from 'vue-router'; -import VAnimateCss from 'v-animate-css'; -import VModal from 'vue-js-modal'; -import VueI18n from 'vue-i18n'; -import SequentialEntrance from 'vue-sequential-entrance'; - -import VueHotkey from './common/hotkey'; -import VueSize from './common/size'; -import App from './app.vue'; -import checkForUpdate from './common/scripts/check-for-update'; -import MiOS from './mios'; -import { version, codename, lang, locale } from './config'; -import { builtinThemes, applyTheme, futureTheme } from './theme'; -import Dialog from './common/views/components/dialog.vue'; - -if (localStorage.getItem('theme') == null) { - applyTheme(futureTheme); -} - -//#region FontAwesome -import { library } from '@fortawesome/fontawesome-svg-core'; -import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; - -import { - faRetweet, - faPlus, - faUser, - faCog, - faCheck, - faStar, - faReply, - faEllipsisH, - faQuoteLeft, - faQuoteRight, - faAngleUp, - faAngleDown, - faAt, - faHashtag, - faHome, - faGlobe, - faCircle, - faList, - faHeart, - faUnlock, - faRssSquare, - faSort, - faChartPie, - faChartBar, - faPencilAlt, - faColumns, - faComments, - faGamepad, - faCloud, - faPowerOff, - faChevronCircleLeft, - faChevronCircleRight, - faShareAlt, - faTimes, - faThumbtack, - faSearch, - faAngleRight, - faWrench, - faTerminal, - faMoon, - faPalette, - faSlidersH, - faDesktop, - faVolumeUp, - faLanguage, - faInfoCircle, - faExclamationTriangle, - faKey, - faBan, - faCogs, - faUnlockAlt, - faPuzzlePiece, - faMobileAlt, - faSignInAlt, - faSyncAlt, - faPaperPlane, - faUpload, - faMapMarkerAlt, - faEnvelope, - faLock, - faFolderOpen, - faBirthdayCake, - faImage, - faEye, - faDownload, - faFileImport, - faLink, - faArrowRight, - faICursor, - faCaretRight, - faReplyAll, - faCamera, - faMinus, - faCaretDown, - faCalculator, - faUsers, - faBars, - faFileImage, - faPollH, - faFolder, - faMicrochip, - faMemory, - faServer, - faExclamationCircle, - faSpinner, - faBroadcastTower, - faChartLine, - faEllipsisV, - faStickyNote, - faUserClock, - faUserPlus, - faExternalLinkSquareAlt, - faSync, - faArrowLeft, - faMapMarker, - faRobot, - faHourglassHalf, - faGavel, - faUndoAlt, -} from '@fortawesome/free-solid-svg-icons'; - -import { - faBell as farBell, - faEnvelope as farEnvelope, - faComments as farComments, - faTrashAlt as farTrashAlt, - faWindowRestore as farWindowRestore, - faFolder as farFolder, - faLaugh as farLaugh, - faSmile as farSmile, - faEyeSlash as farEyeSlash, - faFolderOpen as farFolderOpen, - faSave as farSave, - faImages as farImages, - faChartBar as farChartBar, - faCommentAlt as farCommentAlt, - faClock as farClock, - faCalendarAlt as farCalendarAlt, - faHdd as farHdd, - faMoon as farMoon, - faPlayCircle as farPlayCircle, - faLightbulb as farLightbulb, - faStickyNote as farStickyNote, -} from '@fortawesome/free-regular-svg-icons'; - -import { - faTwitter as fabTwitter, - faGithub as fabGithub, - faDiscord as fabDiscord -} from '@fortawesome/free-brands-svg-icons'; -import i18n from './i18n'; - -library.add( - faRetweet, - faPlus, - faUser, - faCog, - faCheck, - faStar, - faReply, - faEllipsisH, - faQuoteLeft, - faQuoteRight, - faAngleUp, - faAngleDown, - faAt, - faHashtag, - faHome, - faGlobe, - faCircle, - faList, - faHeart, - faUnlock, - faRssSquare, - faSort, - faChartPie, - faChartBar, - faPencilAlt, - faColumns, - faComments, - faGamepad, - faCloud, - faPowerOff, - faChevronCircleLeft, - faChevronCircleRight, - faShareAlt, - faTimes, - faThumbtack, - faSearch, - faAngleRight, - faWrench, - faTerminal, - faMoon, - faPalette, - faSlidersH, - faDesktop, - faVolumeUp, - faLanguage, - faInfoCircle, - faExclamationTriangle, - faKey, - faBan, - faCogs, - faUnlockAlt, - faPuzzlePiece, - faMobileAlt, - faSignInAlt, - faSyncAlt, - faPaperPlane, - faUpload, - faMapMarkerAlt, - faEnvelope, - faLock, - faFolderOpen, - faBirthdayCake, - faImage, - faEye, - faDownload, - faFileImport, - faLink, - faArrowRight, - faICursor, - faCaretRight, - faReplyAll, - faCamera, - faMinus, - faCaretDown, - faCalculator, - faUsers, - faBars, - faFileImage, - faPollH, - faFolder, - faMicrochip, - faMemory, - faServer, - faExclamationCircle, - faSpinner, - faBroadcastTower, - faChartLine, - faEllipsisV, - faStickyNote, - faUserClock, - faUserPlus, - faExternalLinkSquareAlt, - faSync, - faArrowLeft, - faMapMarker, - faRobot, - faHourglassHalf, - faGavel, - faUndoAlt, - - farBell, - farEnvelope, - farComments, - farTrashAlt, - farWindowRestore, - farFolder, - farLaugh, - farSmile, - farEyeSlash, - farFolderOpen, - farSave, - farImages, - farChartBar, - farCommentAlt, - farClock, - farCalendarAlt, - farHdd, - farMoon, - farPlayCircle, - farLightbulb, - farStickyNote, - - fabTwitter, - fabGithub, - fabDiscord -); -//#endregion - -Vue.use(Vuex); -Vue.use(VueRouter); -Vue.use(VAnimateCss); -Vue.use(VModal); -Vue.use(VueHotkey); -Vue.use(VueSize); -Vue.use(VueI18n); -Vue.use(SequentialEntrance); - -Vue.component('fa', FontAwesomeIcon); - -// Register global directives -require('./common/views/directives'); - -// Register global components -require('./common/views/components'); -require('./common/views/widgets'); - -// Register global filters -require('./common/views/filters'); - -Vue.mixin({ - methods: { - destroyDom() { - this.$destroy(); - - if (this.$el.parentNode) { - this.$el.parentNode.removeChild(this.$el); - } - } - } -}); - -/** - * APP ENTRY POINT! - */ - -console.info(`Misskey v${version} (${codename})`); -console.info( - `%c${locale['common']['do-not-copy-paste']}`, - 'color: red; background: yellow; font-size: 16px; font-weight: bold;'); - -// BootTimer解除 -window.clearTimeout((window as any).mkBootTimer); -delete (window as any).mkBootTimer; - -//#region Set lang attr -const html = document.documentElement; -html.setAttribute('lang', lang); -//#endregion - -// iOSでプライベートモードだとlocalStorageが使えないので既存のメソッドを上書きする -try { - localStorage.setItem('kyoppie', 'yuppie'); -} catch (e) { - Storage.prototype.setItem = () => { }; // noop -} - -// クライアントを更新すべきならする -if (localStorage.getItem('should-refresh') == 'true') { - localStorage.removeItem('should-refresh'); - location.reload(true); -} - -// MiOSを初期化してコールバックする -export default (callback: (launch: (router: VueRouter) => [Vue, MiOS], os: MiOS) => void, sw = false) => { - const os = new MiOS(sw); - - os.init(() => { - // アプリ基底要素マウント - document.body.innerHTML = '<div id="app"></div>'; - - const launch = (router: VueRouter) => { - //#region theme - os.store.watch(s => { - return s.device.darkmode; - }, v => { - const themes = os.store.state.device.themes.concat(builtinThemes); - const dark = themes.find(t => t.id == os.store.state.device.darkTheme); - const light = themes.find(t => t.id == os.store.state.device.lightTheme); - applyTheme(v ? dark : light); - }); - os.store.watch(s => { - return s.device.lightTheme; - }, v => { - const themes = os.store.state.device.themes.concat(builtinThemes); - const theme = themes.find(t => t.id == v); - if (!os.store.state.device.darkmode) { - applyTheme(theme); - } - }); - os.store.watch(s => { - return s.device.darkTheme; - }, v => { - const themes = os.store.state.device.themes.concat(builtinThemes); - const theme = themes.find(t => t.id == v); - if (os.store.state.device.darkmode) { - applyTheme(theme); - } - }); - //#endregion - - /*// Reapply current theme - try { - const themeName = os.store.state.device.darkmode ? os.store.state.device.darkTheme : os.store.state.device.lightTheme; - const themes = os.store.state.device.themes.concat(builtinThemes); - const theme = themes.find(t => t.id == themeName); - if (theme) { - applyTheme(theme); - } - } catch (e) { - console.log(`Cannot reapply theme. ${e}`); - }*/ - - //#region line width - document.documentElement.style.setProperty('--lineWidth', `${os.store.state.device.lineWidth}px`); - os.store.watch(s => { - return s.device.lineWidth; - }, v => { - document.documentElement.style.setProperty('--lineWidth', `${os.store.state.device.lineWidth}px`); - }); - //#endregion - - //#region fontSize - document.documentElement.style.setProperty('--fontSize', `${os.store.state.device.fontSize}px`); - os.store.watch(s => { - return s.device.fontSize; - }, v => { - document.documentElement.style.setProperty('--fontSize', `${os.store.state.device.fontSize}px`); - }); - //#endregion - - document.addEventListener('visibilitychange', () => { - if (!document.hidden) { - os.store.commit('clearBehindNotes'); - } - }, false); - - window.addEventListener('scroll', () => { - if (window.scrollY <= 8) { - os.store.commit('clearBehindNotes'); - } - }, { passive: true }); - - const app = new Vue({ - i18n: i18n(), - store: os.store, - data() { - return { - os: { - windows: os.windows - }, - stream: os.stream, - instanceName: os.instanceName - }; - }, - methods: { - api: os.api, - getMeta: os.getMeta, - getMetaSync: os.getMetaSync, - signout: os.signout, - new(vm, props) { - const x = new vm({ - parent: this, - propsData: props - }).$mount(); - document.body.appendChild(x.$el); - return x; - }, - newAsync(vm, props) { - return new Promise((res) => { - vm().then(vm => { - const x = new vm({ - parent: this, - propsData: props - }).$mount(); - document.body.appendChild(x.$el); - res(x); - }); - }); - }, - dialog(opts) { - const vm = this.new(Dialog, opts); - const p: any = new Promise((res) => { - vm.$once('ok', result => res({ canceled: false, result })); - vm.$once('cancel', () => res({ canceled: true })); - }); - p.close = () => { - vm.close(); - }; - return p; - } - }, - router, - render: createEl => createEl(App) - }); - - os.app = app; - - // マウント - app.$mount('#app'); - - //#region 更新チェック - setTimeout(() => { - checkForUpdate(app); - }, 3000); - //#endregion - - return [app, os] as [Vue, MiOS]; - }; - - // Deck mode - os.store.commit('device/set', { - key: 'inDeckMode', - value: os.store.getters.isSignedIn && os.store.state.device.deckMode - && (document.location.pathname === '/' || window.performance.navigation.type === 1) - }); - - callback(launch, os); - }); -}; diff --git a/src/client/app/mobile/script.ts b/src/client/app/mobile/script.ts deleted file mode 100644 index 26ef740811..0000000000 --- a/src/client/app/mobile/script.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Mobile Client - */ - -import Vue from 'vue'; -import VueRouter from 'vue-router'; - -// Style -import './style.styl'; - -import init from '../init'; - -import MkIndex from './views/pages/index.vue'; -import MkSignup from './views/pages/signup.vue'; -import MkSelectDrive from './views/pages/selectdrive.vue'; -import MkDrive from './views/pages/drive.vue'; -import MkNotifications from './views/pages/notifications.vue'; -import MkMessaging from './views/pages/messaging.vue'; -import MkMessagingRoom from './views/pages/messaging-room.vue'; -import MkNote from './views/pages/note.vue'; -import MkSearch from './views/pages/search.vue'; -import UI from './views/pages/ui.vue'; -import MkReversi from './views/pages/games/reversi.vue'; -import MkTag from './views/pages/tag.vue'; -import MkShare from '../common/views/pages/share.vue'; -import MkFollow from '../common/views/pages/follow.vue'; -import MkNotFound from '../common/views/pages/not-found.vue'; -import DeckColumn from '../common/views/deck/deck.column-template.vue'; -import PostFormDialog from './views/components/post-form-dialog.vue'; - -import FileChooser from './views/components/drive-file-chooser.vue'; -import FolderChooser from './views/components/drive-folder-chooser.vue'; - -/** - * init - */ -init((launch, os) => { - Vue.mixin({ - data() { - return { - isMobile: true - }; - }, - - methods: { - $post(opts) { - const o = opts || {}; - - document.documentElement.style.overflow = 'hidden'; - - function recover() { - document.documentElement.style.overflow = 'auto'; - } - - const vm = this.$root.new(PostFormDialog, { - reply: o.reply, - mention: o.mention, - renote: o.renote, - initialText: o.initialText, - instant: o.instant, - initialNote: o.initialNote, - }); - vm.$once('cancel', recover); - vm.$once('posted', recover); - if (o.cb) vm.$once('closed', o.cb); - (vm as any).focus(); - }, - - $chooseDriveFile(opts) { - return new Promise((res, rej) => { - const o = opts || {}; - const vm = this.$root.new(FileChooser, { - title: o.title, - multiple: o.multiple, - initFolder: o.currentFolder - }); - vm.$once('selected', file => { - res(file); - }); - }); - }, - - $chooseDriveFolder(opts) { - return new Promise((res, rej) => { - const o = opts || {}; - const vm = this.$root.new(FolderChooser, { - title: o.title, - initFolder: o.currentFolder - }); - vm.$once('selected', folder => { - res(folder); - }); - }); - }, - - $notify(message) { - alert(message); - } - } - }); - - // Register directives - require('./views/directives'); - - // Register components - require('./views/components'); - require('./views/widgets'); - - // http://qiita.com/junya/items/3ff380878f26ca447f85 - document.body.setAttribute('ontouchstart', ''); - - // Init router - const router = new VueRouter({ - mode: 'history', - routes: [ - ...(os.store.state.device.inDeckMode - ? [{ path: '/', name: 'index', component: () => import('../common/views/deck/deck.vue').then(m => m.default), children: [ - { path: '/@:user', component: () => import('../common/views/deck/deck.user-column.vue').then(m => m.default), children: [ - { path: '', name: 'user', component: () => import('../common/views/deck/deck.user-column.home.vue').then(m => m.default) }, - { path: 'following', component: () => import('../common/views/pages/following.vue').then(m => m.default) }, - { path: 'followers', component: () => import('../common/views/pages/followers.vue').then(m => m.default) }, - ]}, - { path: '/notes/:note', name: 'note', component: () => import('../common/views/deck/deck.note-column.vue').then(m => m.default) }, - { path: '/search', component: () => import('../common/views/deck/deck.search-column.vue').then(m => m.default) }, - { path: '/tags/:tag', name: 'tag', component: () => import('../common/views/deck/deck.hashtag-column.vue').then(m => m.default) }, - { path: '/featured', name: 'featured', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/featured.vue').then(m => m.default), platform: 'deck' }) }, - { path: '/explore', name: 'explore', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default) }) }, - { path: '/explore/tags/:tag', name: 'explore-tag', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default), tag: route.params.tag }) }, - { path: '/i/favorites', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/favorites.vue').then(m => m.default), platform: 'deck' }) }, - { path: '/i/pages', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/pages.vue').then(m => m.default) }) }, - { path: '/i/lists', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-lists.vue').then(m => m.default) }) }, - { path: '/i/lists/:listId', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-list-editor.vue').then(m => m.default), listId: route.params.listId }) }, - { path: '/i/groups', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-groups.vue').then(m => m.default) }) }, - { path: '/i/groups/:groupId', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-group-editor.vue').then(m => m.default), groupId: route.params.groupId }) }, - { path: '/i/follow-requests', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/follow-requests.vue').then(m => m.default) }) }, - { path: '/@:username/pages/:pageName', name: 'page', props: true, component: () => import('../common/views/deck/deck.page-column.vue').then(m => m.default) }, - ]}] - : [ - { path: '/', name: 'index', component: MkIndex }, - ]), - { path: '/signup', name: 'signup', component: MkSignup }, - { path: '/i/settings', name: 'settings', component: () => import('./views/pages/settings.vue').then(m => m.default) }, - { path: '/i/settings/:page', redirect: '/i/settings' }, - { path: '/i/favorites', name: 'favorites', component: UI, props: route => ({ component: () => import('../common/views/pages/favorites.vue').then(m => m.default), platform: 'mobile' }) }, - { path: '/i/pages', name: 'pages', component: UI, props: route => ({ component: () => import('../common/views/pages/pages.vue').then(m => m.default) }) }, - { path: '/i/lists', name: 'user-lists', component: UI, props: route => ({ component: () => import('../common/views/pages/user-lists.vue').then(m => m.default) }) }, - { path: '/i/lists/:list', component: UI, props: route => ({ component: () => import('../common/views/pages/user-list-editor.vue').then(m => m.default), listId: route.params.list }) }, - { path: '/i/groups', name: 'user-groups', component: UI, props: route => ({ component: () => import('../common/views/pages/user-groups.vue').then(m => m.default) }) }, - { path: '/i/groups/:group', component: UI, props: route => ({ component: () => import('../common/views/pages/user-group-editor.vue').then(m => m.default), groupId: route.params.group }) }, - { path: '/i/follow-requests', name: 'follow-requests', component: UI, props: route => ({ component: () => import('../common/views/pages/follow-requests.vue').then(m => m.default) }) }, - { path: '/i/widgets', name: 'widgets', component: () => import('./views/pages/widgets.vue').then(m => m.default) }, - { path: '/i/notifications', name: 'notifications', component: MkNotifications }, - { path: '/i/messaging', name: 'messaging', component: MkMessaging }, - { path: '/i/messaging/group/:group', component: MkMessagingRoom }, - { path: '/i/messaging/:user', component: MkMessagingRoom }, - { path: '/i/drive', name: 'drive', component: MkDrive }, - { path: '/i/drive/folder/:folder', component: MkDrive }, - { path: '/i/drive/file/:file', component: MkDrive }, - { path: '/i/pages/new', component: UI, props: route => ({ component: () => import('../common/views/pages/page-editor/page-editor.vue').then(m => m.default) }) }, - { path: '/i/pages/edit/:pageId', component: UI, props: route => ({ component: () => import('../common/views/pages/page-editor/page-editor.vue').then(m => m.default), initPageId: route.params.pageId }) }, - { path: '/selectdrive', component: MkSelectDrive }, - { path: '/search', component: MkSearch }, - { path: '/tags/:tag', component: MkTag }, - { path: '/featured', name: 'featured', component: UI, props: route => ({ component: () => import('../common/views/pages/featured.vue').then(m => m.default), platform: 'mobile' }) }, - { path: '/explore', name: 'explore', component: UI, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default) }) }, - { path: '/explore/tags/:tag', name: 'explore-tag', component: UI, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default), tag: route.params.tag }) }, - { path: '/share', component: MkShare }, - { path: '/games/reversi/:game?', name: 'reversi', component: MkReversi }, - { path: '/@:user', name: 'user', component: () => import('./views/pages/user/index.vue').then(m => m.default), children: [ - { path: 'following', component: () => import('../common/views/pages/following.vue').then(m => m.default) }, - { path: 'followers', component: () => import('../common/views/pages/followers.vue').then(m => m.default) }, - ]}, - { path: '/@:user/pages/:page', component: UI, props: route => ({ component: () => import('../common/views/pages/page.vue').then(m => m.default), pageName: route.params.page, username: route.params.user }) }, - { path: '/@:user/pages/:pageName/view-source', component: UI, props: route => ({ component: () => import('../common/views/pages/page-editor/page-editor.vue').then(m => m.default), initUser: route.params.user, initPageName: route.params.pageName }) }, - { path: '/@:acct/room', props: true, component: () => import('../common/views/pages/room/room.vue').then(m => m.default) }, - { path: '/notes/:note', component: MkNote }, - { path: '/authorize-follow', component: MkFollow }, - { path: '*', component: MkNotFound } - ] - }); - - // Launch the app - launch(router); -}, true); diff --git a/src/client/app/mobile/style.styl b/src/client/app/mobile/style.styl deleted file mode 100644 index 3a4fc9c0c6..0000000000 --- a/src/client/app/mobile/style.styl +++ /dev/null @@ -1,23 +0,0 @@ -@import "../app" -@import "../reset" - -#wait - top auto - bottom 15px - left 15px - -html - height 100% - background var(--bg) - -main - width 100% - max-width 680px - margin 0 auto - padding 8px - - @media (min-width 500px) - padding 16px - - @media (min-width 600px) - padding 32px diff --git a/src/client/app/mobile/views/components/detail-notes.vue b/src/client/app/mobile/views/components/detail-notes.vue deleted file mode 100644 index bab7949534..0000000000 --- a/src/client/app/mobile/views/components/detail-notes.vue +++ /dev/null @@ -1,52 +0,0 @@ -<template> -<div class="fdcvngpy"> - <sequential-entrance animation="entranceFromTop" delay="25"> - <template v-for="note in notes"> - <mk-note-detail class="post" :note="note" :key="note.id"/> - </template> - </sequential-entrance> - <ui-button v-if="more" @click="fetchMore()">{{ $t('@.load-more') }}</ui-button> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import paging from '../../../common/scripts/paging'; - -export default Vue.extend({ - i18n: i18n(), - - mixins: [ - paging({ - captureWindowScroll: true, - }), - ], - - props: { - pagination: { - required: true - }, - extract: { - required: false - } - }, - - computed: { - notes() { - return this.extract ? this.extract(this.items) : this.items; - } - } -}); -</script> - -<style lang="stylus" scoped> -.fdcvngpy - > * > .post - margin-bottom 8px - - @media (min-width 500px) - > * > .post - margin-bottom 16px - -</style> diff --git a/src/client/app/mobile/views/components/drive-file-chooser.vue b/src/client/app/mobile/views/components/drive-file-chooser.vue deleted file mode 100644 index 8795102f97..0000000000 --- a/src/client/app/mobile/views/components/drive-file-chooser.vue +++ /dev/null @@ -1,106 +0,0 @@ -<template> -<div class="cdxzvcfawjxdyxsekbxbfgtplebnoneb"> - <div class="body"> - <header> - <h1>{{ $t('select-file') }}<span class="count" v-if="files.length > 0">({{ files.length }})</span></h1> - <button class="close" @click="cancel"><fa icon="times"/></button> - <button v-if="multiple" class="ok" @click="ok"><fa icon="check"/></button> - </header> - <x-drive class="drive" ref="browser" - :select-file="true" - :type="type" - :multiple="multiple" - @change-selection="onChangeSelection" - @selected="onSelected" - /> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('mobile/views/components/drive-file-chooser.vue'), - components: { - XDrive: () => import('./drive.vue').then(m => m.default), - }, - props: ['type', 'multiple'], - data() { - return { - files: [] - }; - }, - methods: { - onChangeSelection(files) { - this.files = files; - }, - onSelected(file) { - this.$emit('selected', file); - this.destroyDom(); - }, - cancel() { - this.$emit('canceled'); - this.destroyDom(); - }, - ok() { - this.$emit('selected', this.files); - this.destroyDom(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.cdxzvcfawjxdyxsekbxbfgtplebnoneb - position fixed - z-index 20000 - top 0 - left 0 - width 100% - height 100% - padding 8px - background rgba(#000, 0.2) - - > .body - width 100% - height 100% - background var(--faceHeader) - - > header - border-bottom solid 1px var(--faceDivider) - color var(--text) - - > h1 - margin 0 - padding 0 - text-align center - line-height 42px - font-size 1em - font-weight normal - - > .count - margin-left 4px - opacity 0.5 - - > .close - position absolute - top 0 - left 0 - line-height 42px - width 42px - - > .ok - position absolute - top 0 - right 0 - line-height 42px - width 42px - - > .drive - height calc(100% - 42px) - overflow scroll - -webkit-overflow-scrolling touch - -</style> diff --git a/src/client/app/mobile/views/components/drive-folder-chooser.vue b/src/client/app/mobile/views/components/drive-folder-chooser.vue deleted file mode 100644 index 250a7aca2c..0000000000 --- a/src/client/app/mobile/views/components/drive-folder-chooser.vue +++ /dev/null @@ -1,83 +0,0 @@ -<template> -<div class="mk-drive-folder-chooser"> - <div class="body"> - <header> - <h1>{{ $t('select-folder') }}</h1> - <button class="close" @click="cancel"><fa icon="times"/></button> - <button class="ok" @click="ok"><fa icon="check"/></button> - </header> - <x-drive ref="browser" - select-folder - /> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -export default Vue.extend({ - i18n: i18n('mobile/views/components/drive-folder-chooser.vue'), - components: { - XDrive: () => import('./drive.vue').then(m => m.default), - }, - methods: { - cancel() { - this.$emit('canceled'); - this.destroyDom(); - }, - ok() { - this.$emit('selected', (this.$refs.browser as any).folder); - this.destroyDom(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-drive-folder-chooser - position fixed - z-index 2048 - top 0 - left 0 - width 100% - height 100% - padding 8px - background rgba(#000, 0.2) - - > .body - width 100% - height 100% - background #fff - - > header - border-bottom solid 1px #eee - - > h1 - margin 0 - padding 0 - text-align center - line-height 42px - font-size 1em - font-weight normal - - > .close - position absolute - top 0 - left 0 - line-height 42px - width 42px - - > .ok - position absolute - top 0 - right 0 - line-height 42px - width 42px - - > .mk-drive - height calc(100% - 42px) - overflow scroll - -webkit-overflow-scrolling touch - -</style> diff --git a/src/client/app/mobile/views/components/drive.file-detail.vue b/src/client/app/mobile/views/components/drive.file-detail.vue deleted file mode 100644 index 328982a16b..0000000000 --- a/src/client/app/mobile/views/components/drive.file-detail.vue +++ /dev/null @@ -1,253 +0,0 @@ -<template> -<div class="pyvicwrksnfyhpfgkjwqknuururpaztw"> - <div class="preview"> - <x-file-thumbnail class="preview" :file="file" :detail="true"/> - <template v-if="kind != 'image'"><fa icon="file"/></template> - <footer v-if="kind == 'image' && file.properties && file.properties.width && file.properties.height"> - <span class="size"> - <span class="width">{{ file.properties.width }}</span> - <span class="time">×</span> - <span class="height">{{ file.properties.height }}</span> - <span class="px">px</span> - </span> - <span class="separator"></span> - <span class="aspect-ratio"> - <span class="width">{{ file.properties.width / gcd(file.properties.width, file.properties.height) }}</span> - <span class="colon">:</span> - <span class="height">{{ file.properties.height / gcd(file.properties.width, file.properties.height) }}</span> - </span> - </footer> - </div> - <div class="info"> - <div> - <span class="type"><mk-file-type-icon :type="file.type"/> {{ file.type }}</span> - <span class="separator"></span> - <span class="data-size">{{ file.size | bytes }}</span> - <span class="separator"></span> - <span class="created-at" @click="showCreatedAt"><fa :icon="['far', 'clock']"/><mk-time :time="file.createdAt"/></span> - <template v-if="file.isSensitive"> - <span class="separator"></span> - <span class="nsfw"><fa :icon="['far', 'eye-slash']"/> {{ $t('nsfw') }}</span> - </template> - </div> - </div> - <div class="menu"> - <div> - <ui-input readonly :value="file.url">URL</ui-input> - <ui-button link :href="dlUrl" :download="file.name"><fa icon="download"/> {{ $t('download') }}</ui-button> - <ui-button @click="rename"><fa icon="pencil-alt"/> {{ $t('rename') }}</ui-button> - <ui-button @click="move"><fa :icon="['far', 'folder-open']"/> {{ $t('move') }}</ui-button> - <ui-button @click="toggleSensitive" v-if="file.isSensitive"><fa :icon="['far', 'eye']"/> {{ $t('unmark-as-sensitive') }}</ui-button> - <ui-button @click="toggleSensitive" v-else><fa :icon="['far', 'eye-slash']"/> {{ $t('mark-as-sensitive') }}</ui-button> - <ui-button @click="del"><fa :icon="['far', 'trash-alt']"/> {{ $t('delete') }}</ui-button> - </div> - </div> - <div class="hash"> - <div> - <p> - <fa icon="hashtag"/>{{ $t('hash') }} - </p> - <code>{{ file.md5 }}</code> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { gcd } from '../../../../../prelude/math'; -import XFileThumbnail from '../../../common/views/components/drive-file-thumbnail.vue'; - -export default Vue.extend({ - i18n: i18n('mobile/views/components/drive.file-detail.vue'), - props: ['file'], - - components: { - XFileThumbnail - }, - - data() { - return { - gcd, - exif: null - }; - }, - - computed: { - browser(): any { - return this.$parent; - }, - - kind(): string { - return this.file.type.split('/')[0]; - }, - - style(): any { - return this.file.properties.avgColor ? { - 'background-color': this.file.properties.avgColor - } : {}; - }, - - dlUrl(): string { - return this.file.url; - } - }, - - methods: { - rename() { - const name = window.prompt(this.$t('rename'), this.file.name); - if (name == null || name == '' || name == this.file.name) return; - this.$root.api('drive/files/update', { - fileId: this.file.id, - name: name - }).then(() => { - this.browser.cf(this.file, true); - }); - }, - - move() { - this.$chooseDriveFolder().then(folder => { - this.$root.api('drive/files/update', { - fileId: this.file.id, - folderId: folder == null ? null : folder.id - }).then(() => { - this.browser.cf(this.file, true); - }); - }); - }, - - del() { - this.$root.api('drive/files/delete', { - fileId: this.file.id - }).then(() => { - this.browser.cd(this.file.folderId); - }); - }, - - toggleSensitive() { - this.$root.api('drive/files/update', { - fileId: this.file.id, - isSensitive: !this.file.isSensitive - }); - - this.file.isSensitive = !this.file.isSensitive; - }, - - showCreatedAt() { - this.$root.dialog({ - type: 'info', - text: (new Date(this.file.createdAt)).toLocaleString() - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.pyvicwrksnfyhpfgkjwqknuururpaztw - > .preview - padding 8px - background var(--bg) - - > .preview - width fit-content - width -moz-fit-content - max-width 100% - margin 0 auto - box-shadow 1px 1px 4px rgba(#000, 0.2) - overflow hidden - color var(--driveFileIcon) - justify-content center - - > footer - padding 8px 8px 0 8px - text-align center - font-size 0.8em - color var(--text) - opacity 0.7 - - > .separator - display inline - padding 0 4px - - > .size - display inline - - .time - margin 0 2px - - .px - margin-left 4px - - > .aspect-ratio - display inline - opacity 0.7 - - &:before - content "(" - - &:after - content ")" - - > .info - padding 14px - font-size 0.8em - border-top solid 1px var(--faceDivider) - - > div - max-width 500px - margin 0 auto - color var(--text) - - > .separator - padding 0 4px - - > .created-at - - > [data-icon] - margin-right 2px - - > .nsfw - color #bf4633 - - > .menu - padding 0 14px 14px 14px - border-top solid 1px var(--faceDivider) - - > div - max-width 500px - margin 0 auto - - > .hash - padding 14px - border-top solid 1px var(--faceDivider) - - > div - max-width 500px - margin 0 auto - - > p - display block - margin 0 - padding 0 - color var(--text) - font-size 0.9em - - > [data-icon] - margin-right 4px - - > code - display block - width 100% - margin 6px 0 0 0 - padding 8px - white-space nowrap - overflow auto - font-size 0.8em - color #222 - border solid 1px #dfdfdf - border-radius 2px - background #f5f5f5 - -</style> diff --git a/src/client/app/mobile/views/components/drive.file.vue b/src/client/app/mobile/views/components/drive.file.vue deleted file mode 100644 index ed95537f9c..0000000000 --- a/src/client/app/mobile/views/components/drive.file.vue +++ /dev/null @@ -1,155 +0,0 @@ -<template> -<a class="vupkuhvjnjyqaqhsiogfbywvjxynrgsm" @click.prevent="onClick" :href="`/i/drive/file/${ file.id }`" :data-is-selected="isSelected"> - <div class="container"> - <x-file-thumbnail class="thumbnail" :file="file" fit="cover"/> - <div class="body"> - <p class="name"> - <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> - <span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> - </p> - <footer> - <span class="type"><mk-file-type-icon :type="file.type"/>{{ file.type }}</span> - <span class="separator"></span> - <span class="data-size">{{ file.size | bytes }}</span> - <span class="separator"></span> - <span class="created-at"><fa :icon="['far', 'clock']"/><mk-time :time="file.createdAt"/></span> - <template v-if="file.isSensitive"> - <span class="separator"></span> - <span class="nsfw"><fa :icon="['far', 'eye-slash']"/> {{ $t('nsfw') }}</span> - </template> - </footer> - </div> - </div> -</a> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XFileThumbnail from '../../../common/views/components/drive-file-thumbnail.vue'; - -export default Vue.extend({ - i18n: i18n('mobile/views/components/drive.file.vue'), - props: ['file'], - components: { - XFileThumbnail - }, - data() { - return { - isSelected: false - }; - }, - computed: { - browser(): any { - return this.$parent; - } - }, - created() { - this.isSelected = this.browser.selectedFiles.some(f => f.id == this.file.id) - - this.browser.$on('change-selection', this.onBrowserChangeSelection); - }, - beforeDestroy() { - this.browser.$off('change-selection', this.onBrowserChangeSelection); - }, - methods: { - onBrowserChangeSelection(selections) { - this.isSelected = selections.some(f => f.id == this.file.id); - }, - onClick() { - this.browser.chooseFile(this.file); - } - } -}); -</script> - -<style lang="stylus" scoped> -.vupkuhvjnjyqaqhsiogfbywvjxynrgsm - display block - text-decoration none !important - - * - user-select none - pointer-events none - - > .container - display grid - max-width 500px - margin 0 auto - padding 16px - grid-template-columns 64px 1fr - grid-column-gap 10px - - &:after - content "" - display block - clear both - - > .thumbnail - width 64px - height 64px - color var(--driveFileIcon) - - > .body - display block - word-break break-all - - > .name - display block - margin 0 - padding 0 - font-size 0.9em - font-weight bold - color var(--text) - word-break break-word - - > .ext - opacity 0.5 - - > .tags - display block - margin 4px 0 0 0 - padding 0 - list-style none - font-size 0.5em - - > .tag - display inline-block - margin 0 5px 0 0 - padding 1px 5px - border-radius 2px - - > footer - display block - margin 4px 0 0 0 - font-size 0.7em - color var(--text) - - > .separator - padding 0 4px - - > .type - opacity 0.7 - - > .mk-file-type-icon - margin-right 4px - - > .data-size - opacity 0.7 - - > .created-at - opacity 0.7 - - > [data-icon] - margin-right 2px - - > .nsfw - color #bf4633 - - &[data-is-selected] - background var(--primary) - - &, * - color var(--primaryForeground) !important - -</style> diff --git a/src/client/app/mobile/views/components/drive.folder.vue b/src/client/app/mobile/views/components/drive.folder.vue deleted file mode 100644 index 0959c1e7d4..0000000000 --- a/src/client/app/mobile/views/components/drive.folder.vue +++ /dev/null @@ -1,56 +0,0 @@ -<template> -<a class="jvwxssxsytqlqvrpiymarjlzlsxskqsr" @click.prevent="onClick" :href="`/i/drive/folder/${ folder.id }`"> - <div class="container"> - <p class="name"><fa icon="folder"/>{{ folder.name }}</p><fa icon="angle-right"/> - </div> -</a> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - props: ['folder'], - computed: { - browser(): any { - return this.$parent; - } - }, - methods: { - onClick() { - this.browser.cd(this.folder); - } - } -}); -</script> - -<style lang="stylus" scoped> -.jvwxssxsytqlqvrpiymarjlzlsxskqsr - display block - color var(--text) - text-decoration none !important - - * - user-select none - pointer-events none - - > .container - max-width 500px - margin 0 auto - padding 16px - - > .name - display block - margin 0 - padding 0 - - > [data-icon] - margin-right 6px - - > [data-icon] - position absolute - top 0 - bottom 0 - right 20px - height 100% - -</style> diff --git a/src/client/app/mobile/views/components/drive.vue b/src/client/app/mobile/views/components/drive.vue deleted file mode 100644 index fe193f311a..0000000000 --- a/src/client/app/mobile/views/components/drive.vue +++ /dev/null @@ -1,618 +0,0 @@ -<template> -<div class="kmmwchoexgckptowjmjgfsygeltxfeqs"> - <nav ref="nav"> - <a @click.prevent="goRoot()" href="/i/drive"><fa icon="cloud"/>{{ $t('@.drive') }}</a> - <template v-for="folder in hierarchyFolders"> - <span :key="folder.id + '>'"><fa icon="angle-right"/></span> - <a :key="folder.id" @click.prevent="cd(folder)" :href="`/i/drive/folder/${folder.id}`">{{ folder.name }}</a> - </template> - <template v-if="folder != null"> - <span><fa icon="angle-right"/></span> - <p>{{ folder.name }}</p> - </template> - <template v-if="file != null"> - <span><fa icon="angle-right"/></span> - <p>{{ file.name }}</p> - </template> - </nav> - <mk-uploader ref="uploader"/> - <div class="browser" :class="{ fetching }" v-if="file == null"> - <div class="info" v-if="info"> - <p v-if="folder == null">{{ (info.usage / info.capacity * 100).toFixed(1) }}% {{ $t('used') }}</p> - <p v-if="folder != null && (folder.foldersCount > 0 || folder.filesCount > 0)"> - <template v-if="folder.foldersCount > 0">{{ folder.foldersCount }} {{ $t('folder-count') }}</template> - <template v-if="folder.foldersCount > 0 && folder.filesCount > 0">{{ $t('count-separator') }}</template> - <template v-if="folder.filesCount > 0">{{ folder.filesCount }} {{ $t('file-count') }}</template> - </p> - </div> - <div class="folders" v-if="folders.length > 0 || moreFolders"> - <x-folder class="folder" v-for="folder in folders" :key="folder.id" :folder="folder"/> - <p v-if="moreFolders">{{ $t('@.load-more') }}</p> - </div> - <div class="files" v-if="files.length > 0 || moreFiles"> - <x-file class="file" v-for="file in files" :key="file.id" :file="file"/> - <button class="more" v-if="moreFiles" @click="fetchMoreFiles"> - {{ fetchingMoreFiles ? this.$t('@.loading') : this.$t('@.load-more') }} - </button> - </div> - <div class="empty" v-if="files.length == 0 && !moreFiles && folders.length == 0 && !moreFolders && !fetching"> - <p v-if="folder == null">{{ $t('nothing-in-drive') }}</p> - <p v-if="folder != null">{{ $t('folder-is-empty') }}</p> - </div> - </div> - <div class="fetching" v-if="fetching && file == null && files.length == 0 && folders.length == 0"> - <div class="spinner"> - <div class="dot1"></div> - <div class="dot2"></div> - </div> - </div> - <input ref="file" class="file" type="file" multiple="multiple" @change="onChangeLocalFile"/> - <x-file-detail v-if="file != null" :file="file"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XFolder from './drive.folder.vue'; -import XFile from './drive.file.vue'; -import XFileDetail from './drive.file-detail.vue'; - -export default Vue.extend({ - i18n: i18n('mobile/views/components/drive.vue'), - components: { - XFolder, - XFile, - XFileDetail - }, - props: ['initFolder', 'initFile', 'selectFile', 'multiple', 'isNaked', 'top'], - data() { - return { - /** - * 現在の階層(フォルダ) - * * null でルートを表す - */ - folder: null, - - file: null, - - files: [], - folders: [], - moreFiles: false, - moreFolders: false, - hierarchyFolders: [], - selectedFiles: [], - info: null, - connection: null, - - fetching: true, - fetchingMoreFiles: false, - fetchingMoreFolders: false - }; - }, - computed: { - isFileSelectMode(): boolean { - return this.selectFile; - } - }, - watch: { - top() { - if (this.isNaked) { - (this.$refs.nav as any).style.top = `${this.top}px`; - } - } - }, - mounted() { - this.connection = this.$root.stream.useSharedConnection('drive'); - - this.connection.on('fileCreated', this.onStreamDriveFileCreated); - this.connection.on('fileUpdated', this.onStreamDriveFileUpdated); - this.connection.on('fileDeleted', this.onStreamDriveFileDeleted); - this.connection.on('folderCreated', this.onStreamDriveFolderCreated); - this.connection.on('folderUpdated', this.onStreamDriveFolderUpdated); - - if (this.initFolder) { - this.cd(this.initFolder, true); - } else if (this.initFile) { - this.cf(this.initFile, true); - } else { - this.fetch(); - } - - if (this.isNaked) { - (this.$refs.nav as any).style.top = `${this.top}px`; - } - }, - beforeDestroy() { - this.connection.dispose(); - }, - methods: { - onStreamDriveFileCreated(file) { - this.addFile(file, true); - }, - - onStreamDriveFileUpdated(file) { - const current = this.folder ? this.folder.id : null; - if (current != file.folderId) { - this.removeFile(file); - } else { - this.addFile(file, true); - } - }, - - onStreamDriveFileDeleted(fileId) { - this.removeFile(fileId); - }, - - onStreamDriveFolderCreated(folder) { - this.addFolder(folder, true); - }, - - onStreamDriveFolderUpdated(folder) { - const current = this.folder ? this.folder.id : null; - if (current != folder.parentId) { - this.removeFolder(folder); - } else { - this.addFolder(folder, true); - } - }, - - dive(folder) { - this.hierarchyFolders.unshift(folder); - if (folder.parent) this.dive(folder.parent); - }, - - cd(target, silent = false) { - if (target == null) { - this.goRoot(silent); - return; - } else if (typeof target == 'object') { - target = target.id; - } - - this.file = null; - this.fetching = true; - - this.$root.api('drive/folders/show', { - folderId: target - }).then(folder => { - this.folder = folder; - this.hierarchyFolders = []; - - if (folder.parent) this.dive(folder.parent); - - this.$emit('open-folder', this.folder, silent); - this.fetch(); - }); - }, - - addFolder(folder, unshift = false) { - const current = this.folder ? this.folder.id : null; - // 追加しようとしているフォルダが、今居る階層とは違う階層のものだったら中断 - if (current != folder.parentId) return; - - // 追加しようとしているフォルダを既に所有してたら中断 - if (this.folders.some(f => f.id == folder.id)) return; - - if (unshift) { - this.folders.unshift(folder); - } else { - this.folders.push(folder); - } - }, - - addFile(file, unshift = false) { - const current = this.folder ? this.folder.id : null; - // 追加しようとしているファイルが、今居る階層とは違う階層のものだったら中断 - if (current != file.folderId) return; - - if (this.files.some(f => f.id == file.id)) { - const exist = this.files.map(f => f.id).indexOf(file.id); - Vue.set(this.files, exist, file); - return; - } - - if (unshift) { - this.files.unshift(file); - } else { - this.files.push(file); - } - }, - - removeFolder(folder) { - if (typeof folder == 'object') folder = folder.id; - this.folders = this.folders.filter(f => f.id != folder); - }, - - removeFile(file) { - if (typeof file == 'object') file = file.id; - this.files = this.files.filter(f => f.id != file); - }, - - appendFile(file) { - this.addFile(file); - }, - appendFolder(folder) { - this.addFolder(folder); - }, - prependFile(file) { - this.addFile(file, true); - }, - prependFolder(folder) { - this.addFolder(folder, true); - }, - - goRoot(silent = false) { - // すでにrootにいるなら何もしない - if (this.folder == null && this.file == null) return; - - this.file = null; - this.folder = null; - this.hierarchyFolders = []; - this.$emit('move-root', silent); - this.fetch(); - }, - - fetch() { - this.folders = []; - this.files = []; - this.moreFolders = false; - this.moreFiles = false; - this.fetching = true; - - this.$emit('begin-fetch'); - - let fetchedFolders = null; - let fetchedFiles = null; - - const foldersMax = 20; - const filesMax = 20; - - // フォルダ一覧取得 - this.$root.api('drive/folders', { - folderId: this.folder ? this.folder.id : null, - limit: foldersMax + 1 - }).then(folders => { - if (folders.length == foldersMax + 1) { - this.moreFolders = true; - folders.pop(); - } - fetchedFolders = folders; - complete(); - }); - - // ファイル一覧取得 - this.$root.api('drive/files', { - folderId: this.folder ? this.folder.id : null, - limit: filesMax + 1 - }).then(files => { - if (files.length == filesMax + 1) { - this.moreFiles = true; - files.pop(); - } - fetchedFiles = files; - complete(); - }); - - let flag = false; - const complete = () => { - if (flag) { - for (const x of fetchedFolders) this.appendFolder(x); - for (const x of fetchedFiles) this.appendFile(x); - this.fetching = false; - - // 一連の読み込みが完了したイベントを発行 - this.$emit('fetched'); - } else { - flag = true; - // 一連の読み込みが半分完了したイベントを発行 - this.$emit('fetch-mid'); - } - }; - - if (this.folder == null) { - // Fetch addtional drive info - this.$root.api('drive').then(info => { - this.info = info; - }); - } - }, - - fetchMoreFiles() { - this.fetching = true; - this.fetchingMoreFiles = true; - - const max = 30; - - // ファイル一覧取得 - this.$root.api('drive/files', { - folderId: this.folder ? this.folder.id : null, - limit: max + 1, - untilId: this.files[this.files.length - 1].id - }).then(files => { - if (files.length == max + 1) { - this.moreFiles = true; - files.pop(); - } else { - this.moreFiles = false; - } - for (const x of files) this.appendFile(x); - this.fetching = false; - this.fetchingMoreFiles = false; - }); - }, - - chooseFile(file) { - if (this.isFileSelectMode) { - if (this.multiple) { - if (this.selectedFiles.some(f => f.id == file.id)) { - this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id); - } else { - this.selectedFiles.push(file); - } - this.$emit('change-selection', this.selectedFiles); - } else { - this.$emit('selected', file); - } - } else { - this.cf(file); - } - }, - - cf(file, silent = false) { - if (typeof file == 'object') file = file.id; - - this.fetching = true; - - this.$root.api('drive/files/show', { - fileId: file - }).then(file => { - this.file = file; - this.folder = null; - this.hierarchyFolders = []; - - if (file.folder) this.dive(file.folder); - - this.fetching = false; - - this.$emit('open-file', this.file, silent); - }); - }, - - selectLocalFile() { - (this.$refs.file as any).click(); - }, - - createFolder() { - this.$root.dialog({ - title: this.$t('folder-name'), - input: { - default: this.folder.name - } - }).then(({ result: name }) => { - if (!name) { - this.$root.dialog({ - type: 'error', - text: this.$t('folder-name-cannot-empty') - }); - return; - } - this.$root.api('drive/folders/create', { - name: name, - parentId: this.folder ? this.folder.id : undefined - }).then(folder => { - this.addFolder(folder, true); - }); - }); - }, - - renameFolder() { - if (this.folder == null) { - this.$root.dialog({ - type: 'error', - text: this.$t('here-is-root') - }); - return; - } - this.$root.dialog({ - title: this.$t('folder-name'), - input: { - default: this.folder.name - } - }).then(({ result: name }) => { - if (!name) { - this.$root.dialog({ - type: 'error', - text: this.$t('cannot-empty') - }); - return; - } - this.$root.api('drive/folders/update', { - name: name, - folderId: this.folder.id - }).then(folder => { - this.cd(folder); - }); - }); - }, - - moveFolder() { - if (this.folder == null) { - this.$root.dialog({ - type: 'error', - text: this.$t('here-is-root') - }); - return; - } - this.$chooseDriveFolder().then(folder => { - this.$root.api('drive/folders/update', { - parentId: folder ? folder.id : null, - folderId: this.folder.id - }).then(folder => { - this.cd(folder); - }); - }); - }, - - urlUpload() { - const url = window.prompt(this.$t('url-prompt')); - if (url == null || url == '') return; - this.$root.api('drive/files/upload_from_url', { - url: url, - folderId: this.folder ? this.folder.id : undefined - }); - this.$root.dialog({ - type: 'info', - text: this.$t('uploading') - }); - }, - - onChangeLocalFile() { - for (const f of Array.from((this.$refs.file as any).files)) { - (this.$refs.uploader as any).upload(f, this.folder); - } - }, - - deleteFolder() { - if (this.folder == null) { - this.$root.dialog({ - type: 'error', - text: this.$t('here-is-root') - }); - return; - } - this.$root.api('drive/folders/delete', { - folderId: this.folder.id - }).then(folder => { - this.cd(this.folder.parentId); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.kmmwchoexgckptowjmjgfsygeltxfeqs - background var(--face) - - > nav - display block - position sticky - position -webkit-sticky - top 0 - z-index 1 - width 100% - padding 10px 12px - overflow auto - white-space nowrap - font-size 0.9em - color var(--text) - -webkit-backdrop-filter blur(12px) - backdrop-filter blur(12px) - background-color var(--mobileDriveNavBg) - border-bottom solid 1px rgba(#000, 0.13) - - > p - > a - display inline - margin 0 - padding 0 - text-decoration none !important - color inherit - - &:last-child - font-weight bold - - > [data-icon] - margin-right 4px - - > span - margin 0 8px - opacity 0.5 - - > .browser - &.fetching - opacity 0.5 - - > .info - border-bottom solid 1px var(--faceDivider) - - &:empty - display none - - > p - display block - max-width 500px - margin 0 auto - padding 4px 16px - font-size 10px - color var(--text) - - > .folders - > .folder - border-bottom solid 1px var(--faceDivider) - - > .files - > .file - border-bottom solid 1px var(--faceDivider) - - > .more - display block - width 100% - padding 16px - font-size 16px - color #555 - - > .empty - padding 16px - text-align center - color #999 - pointer-events none - - > p - margin 0 - - > .fetching - .spinner - margin 100px auto - width 40px - height 40px - text-align center - - animation sk-rotate 2.0s infinite linear - - .dot1, .dot2 - width 60% - height 60% - display inline-block - position absolute - top 0 - background rgba(#000, 0.2) - border-radius 100% - - animation sk-bounce 2.0s infinite ease-in-out - - .dot2 - top auto - bottom 0 - animation-delay -1.0s - - @keyframes sk-rotate { - 100% { - transform: rotate(360deg); - } - } - - @keyframes sk-bounce { - 0%, 100% { - transform: scale(0.0); - } - 50% { - transform: scale(1.0); - } - } - - > .file - display none - -</style> diff --git a/src/client/app/mobile/views/components/index.ts b/src/client/app/mobile/views/components/index.ts deleted file mode 100644 index 4e10d80f92..0000000000 --- a/src/client/app/mobile/views/components/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import Vue from 'vue'; - -import ui from './ui.vue'; -import note from './note.vue'; -import notes from './notes.vue'; -import mediaVideo from './media-video.vue'; -import notePreview from './note-preview.vue'; -import subNoteContent from './sub-note-content.vue'; -import noteCard from './note-card.vue'; -import noteDetail from './note-detail.vue'; -import notification from './notification.vue'; -import notifications from './notifications.vue'; -import notificationPreview from './notification-preview.vue'; -import userTimeline from './user-timeline.vue'; -import userListTimeline from './user-list-timeline.vue'; -import uiContainer from './ui-container.vue'; - -Vue.component('mk-ui', ui); -Vue.component('mk-note', note); -Vue.component('mk-notes', notes); -Vue.component('mk-media-video', mediaVideo); -Vue.component('mk-note-preview', notePreview); -Vue.component('mk-sub-note-content', subNoteContent); -Vue.component('mk-note-card', noteCard); -Vue.component('mk-note-detail', noteDetail); -Vue.component('mk-notification', notification); -Vue.component('mk-notifications', notifications); -Vue.component('mk-notification-preview', notificationPreview); -Vue.component('mk-user-timeline', userTimeline); -Vue.component('mk-user-list-timeline', userListTimeline); -Vue.component('ui-container', uiContainer); diff --git a/src/client/app/mobile/views/components/note-card.vue b/src/client/app/mobile/views/components/note-card.vue deleted file mode 100644 index 2704820318..0000000000 --- a/src/client/app/mobile/views/components/note-card.vue +++ /dev/null @@ -1,93 +0,0 @@ -<template> -<div class="mk-note-card"> - <a :href="note | notePage"> - <header> - <img :src="avator" alt="avatar"/> - <h3><mk-user-name :user="note.user"/></h3> - </header> - <div> - {{ text }} - </div> - <mk-time :time="note.createdAt"/> - </a> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import summary from '../../../../../misc/get-note-summary'; -import { getStaticImageUrl } from '../../../common/scripts/get-static-image-url'; - -export default Vue.extend({ - props: ['note'], - computed: { - text(): string { - return summary(this.note); - }, - avator(): string { - return this.$store.state.device.disableShowingAnimatedImages - ? getStaticImageUrl(this.note.user.avatarUrl) - : this.note.user.avatarUrl; - }, - } -}); -</script> - -<style lang="stylus" scoped> -.mk-note-card - display inline-block - width 150px - //height 120px - font-size 12px - background var(--face) - border-radius 4px - box-shadow 0 2px 8px rgba(0, 0, 0, 0.2) - - > a - display block - color var(--noteText) - - &:hover - text-decoration none - - > header - > img - position absolute - top 8px - left 8px - width 28px - height 28px - border-radius 6px - - > h3 - display inline-block - overflow hidden - width calc(100% - 45px) - margin 8px 0 0 42px - line-height 28px - white-space nowrap - text-overflow ellipsis - font-size 12px - - > div - padding 2px 8px 8px 8px - height 60px - overflow hidden - white-space normal - - &:after - content "" - display block - position absolute - top 40px - left 0 - width 100% - height 20px - background linear-gradient(to bottom, transparent 0%, var(--face) 100%) - - > .mk-time - display inline-block - padding 8px - color var(--text) - -</style> diff --git a/src/client/app/mobile/views/components/note-detail.vue b/src/client/app/mobile/views/components/note-detail.vue deleted file mode 100644 index 358b827a5c..0000000000 --- a/src/client/app/mobile/views/components/note-detail.vue +++ /dev/null @@ -1,351 +0,0 @@ -<template> -<div class="mk-note-detail" tabindex="-1" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> - <button - class="more" - v-if="appearNote.reply && appearNote.reply.replyId && conversation.length == 0" - @click="fetchConversation" - :disabled="conversationFetching" - > - <template v-if="!conversationFetching"><fa icon="ellipsis-v"/></template> - <template v-if="conversationFetching"><fa icon="spinner" pulse/></template> - </button> - <div class="conversation"> - <x-sub v-for="note in conversation" :key="note.id" :note="note"/> - </div> - <div class="reply-to" v-if="appearNote.reply"> - <x-sub :note="appearNote.reply"/> - </div> - <mk-renote class="renote" v-if="isRenote" :note="note" mini/> - <article> - <header> - <mk-avatar class="avatar" :user="appearNote.user"/> - <div> - <router-link class="name" :to="appearNote.user | userPage"><mk-user-name :user="appearNote.user"/></router-link> - <span class="username"><mk-acct :user="appearNote.user"/></span> - </div> - </header> - <div class="body"> - <p v-if="appearNote.cw != null" class="cw"> - <mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" /> - <mk-cw-button v-model="showContent" :note="appearNote"/> - </p> - <div class="content" v-show="appearNote.cw == null || showContent"> - <div class="text"> - <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $t('private') }})</span> - <span v-if="appearNote.deletedAt" style="opacity: 0.5">({{ $t('deleted') }})</span> - <mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/> - </div> - <div class="files" v-if="appearNote.files.length > 0"> - <mk-media-list :media-list="appearNote.files" :raw="true"/> - </div> - <mk-poll v-if="appearNote.poll" :note="appearNote"/> - <mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/> - <a class="location" v-if="appearNote.geo" :href="`https://maps.google.com/maps?q=${appearNote.geo.coordinates[1]},${appearNote.geo.coordinates[0]}`" rel="noopener" target="_blank"><fa icon="map-marker-alt"/> {{ $t('location') }}</a> - <div class="map" v-if="appearNote.geo" ref="map"></div> - <div class="renote" v-if="appearNote.renote"> - <mk-note-preview :note="appearNote.renote"/> - </div> - </div> - </div> - <router-link class="time" :to="appearNote | notePage"> - <mk-time :time="appearNote.createdAt" mode="detail"/> - </router-link> - <div class="visibility-info"> - <span class="visibility" v-if="appearNote.visibility != 'public'"> - <fa v-if="appearNote.visibility == 'home'" icon="home"/> - <fa v-if="appearNote.visibility == 'followers'" icon="unlock"/> - <fa v-if="appearNote.visibility == 'specified'" icon="envelope"/> - </span> - <span class="localOnly" v-if="appearNote.localOnly == true"><fa icon="heart"/></span> - </div> - <footer> - <mk-reactions-viewer :note="appearNote"/> - <button @click="reply()" :title="$t('title')"> - <template v-if="appearNote.reply"><fa icon="reply-all"/></template> - <template v-else><fa icon="reply"/></template> - <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> - </button> - <button v-if="['public', 'home'].includes(appearNote.visibility)" @click="renote()" title="Renote"> - <fa icon="retweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> - </button> - <button v-else> - <fa icon="ban"/> - </button> - <button v-if="!isMyNote && appearNote.myReaction == null" class="reactionButton" @click="react()" ref="reactButton"> - <fa icon="plus"/> - </button> - <button v-if="!isMyNote && appearNote.myReaction != null" class="reactionButton reacted" @click="undoReact(appearNote)" ref="reactButton"> - <fa icon="minus"/> - </button> - <button @click="menu()" ref="menuButton"> - <fa icon="ellipsis-h"/> - </button> - </footer> - </article> - <div class="replies" v-if="!compact"> - <x-sub v-for="note in replies" :key="note.id" :note="note"/> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XSub from './note.sub.vue'; -import noteSubscriber from '../../../common/scripts/note-subscriber'; -import noteMixin from '../../../common/scripts/note-mixin'; - -export default Vue.extend({ - i18n: i18n('mobile/views/components/note-detail.vue'), - - components: { - XSub - }, - - mixins: [noteMixin(), noteSubscriber('note')], - - props: { - note: { - type: Object, - required: true - }, - compact: { - default: false - } - }, - - data() { - return { - conversation: [], - conversationFetching: false, - replies: [] - }; - }, - - watch: { - note() { - this.fetchReplies(); - } - }, - - mounted() { - this.fetchReplies(); - }, - - methods: { - fetchReplies() { - if (this.compact) return; - this.$root.api('notes/children', { - noteId: this.appearNote.id, - limit: 30 - }).then(replies => { - this.replies = replies; - }); - }, - - fetchConversation() { - this.conversationFetching = true; - - // Fetch conversation - this.$root.api('notes/conversation', { - noteId: this.appearNote.replyId - }).then(conversation => { - this.conversationFetching = false; - this.conversation = conversation.reverse(); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-note-detail - overflow hidden - width 100% - text-align left - background var(--face) - - &.round - border-radius 8px - - &.shadow - box-shadow 0 4px 16px rgba(#000, 0.1) - - @media (min-width 500px) - box-shadow 0 8px 32px rgba(#000, 0.1) - - > .fetching - padding 64px 0 - - > .more - display block - margin 0 - padding 10px 0 - width 100% - font-size 1em - text-align center - color var(--text) - cursor pointer - background var(--subNoteBg) - outline none - border none - border-bottom solid 1px var(--faceDivider) - border-radius 6px 6px 0 0 - box-shadow none - - &:hover - box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05) - - &:active - box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1) - - > .conversation - > * - border-bottom 1px solid var(--faceDivider) - - > .renote + article - padding-top 8px - - > .reply-to - border-bottom 1px solid var(--faceDivider) - - > article - padding 14px 16px 9px 16px - - @media (min-width 500px) - padding 28px 32px 18px 32px - - > header - display flex - line-height 1.1em - - > .avatar - display block - margin 0 12px 0 0 - width 54px - height 54px - border-radius 8px - - @media (min-width 500px) - width 60px - height 60px - - > div - min-width 0 - - > .name - display inline-block - margin .4em 0 - color var(--noteHeaderName) - font-size 16px - font-weight bold - text-align left - text-decoration none - - &:hover - text-decoration underline - - > .username - display block - text-align left - margin 0 - color var(--noteHeaderAcct) - - > .body - padding 8px 0 - - > .cw - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - color var(--noteText) - - > .text - margin-right 8px - - > .content - - > .text - display block - margin 0 - padding 0 - overflow-wrap break-word - font-size 16px - color var(--noteText) - - @media (min-width 500px) - font-size 24px - - > .renote - margin 8px 0 - - > * - padding 16px - border dashed 1px var(--quoteBorder) - border-radius 8px - - > .location - margin 4px 0 - font-size 12px - color var(--text) - - > .map - width 100% - height 200px - - &:empty - display none - - > .mk-url-preview - margin-top 8px - - > .files - > img - display block - max-width 100% - - > .time - font-size 16px - color var(--noteHeaderInfo) - - > .visibility-info - color var(--noteHeaderInfo) - - > .localOnly - margin-left 4px - - > footer - font-size 1.2em - - > button - margin 0 - padding 8px - background transparent - border none - box-shadow none - font-size 1em - color var(--noteActions) - cursor pointer - - &:not(:last-child) - margin-right 28px - - &:hover - color var(--noteActionsHover) - - > .count - display inline - margin 0 0 0 8px - color var(--text) - opacity 0.7 - - &.reacted - color var(--primary) - - > .replies - > * - border-top 1px solid var(--faceDivider) - -</style> diff --git a/src/client/app/mobile/views/components/note-preview.vue b/src/client/app/mobile/views/components/note-preview.vue deleted file mode 100644 index 1dbbddaa62..0000000000 --- a/src/client/app/mobile/views/components/note-preview.vue +++ /dev/null @@ -1,114 +0,0 @@ -<template> -<div class="yohlumlkhizgfkvvscwfcrcggkotpvry" :class="{ smart: $store.state.device.postStyle == 'smart', mini: narrow }"> - <mk-avatar class="avatar" :user="note.user" v-if="$store.state.device.postStyle != 'smart' && !narrow"/> - <div class="main"> - <mk-note-header class="header" :note="note" :mini="true"/> - <div class="body"> - <p v-if="note.cw != null" class="cw"> - <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> - <mk-cw-button v-model="showContent" :note="note"/> - </p> - <div class="content" v-show="note.cw == null || showContent"> - <mk-sub-note-content class="text" :note="note"/> - </div> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: { - note: { - type: Object, - required: true - } - }, - - inject: { - narrow: { - default: false - } - }, - - data() { - return { - showContent: false - }; - } -}); -</script> - -<style lang="stylus" scoped> -.yohlumlkhizgfkvvscwfcrcggkotpvry - display flex - margin 0 - padding 0 - overflow hidden - font-size 10px - - &:not(.mini) - - @media (min-width 350px) - font-size 12px - - @media (min-width 500px) - font-size 14px - - > .avatar - - @media (min-width 350px) - margin 0 10px 0 0 - width 44px - height 44px - - @media (min-width 500px) - margin 0 12px 0 0 - width 48px - height 48px - - &.smart - > .main - width 100% - - > header - align-items center - - > .avatar - flex-shrink 0 - display block - margin 0 10px 0 0 - width 40px - height 40px - border-radius 8px - - > .main - flex 1 - min-width 0 - - > .header - margin-bottom 2px - - > .body - - > .cw - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - color var(--noteText) - - > .text - margin-right 8px - - > .content - > .text - cursor default - margin 0 - padding 0 - color var(--subNoteText) - -</style> diff --git a/src/client/app/mobile/views/components/note.sub.vue b/src/client/app/mobile/views/components/note.sub.vue deleted file mode 100644 index b951947f2a..0000000000 --- a/src/client/app/mobile/views/components/note.sub.vue +++ /dev/null @@ -1,124 +0,0 @@ -<template> -<div class="zlrxdaqttccpwhpaagdmkawtzklsccam" :class="{ smart: $store.state.device.postStyle == 'smart', mini: narrow }"> - <mk-avatar class="avatar" :user="note.user" v-if="$store.state.device.postStyle != 'smart'"/> - <div class="main"> - <mk-note-header class="header" :note="note" :mini="true"/> - <div class="body"> - <p v-if="note.cw != null" class="cw"> - <mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis" /> - <mk-cw-button v-model="showContent" :note="note"/> - </p> - <div class="content" v-show="note.cw == null || showContent"> - <mk-sub-note-content class="text" :note="note"/> - </div> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: { - note: { - type: Object, - required: true - }, - // TODO - truncate: { - type: Boolean, - default: true - } - }, - - inject: { - narrow: { - default: false - } - }, - - data() { - return { - showContent: false - }; - } -}); -</script> - -<style lang="stylus" scoped> -.zlrxdaqttccpwhpaagdmkawtzklsccam - display flex - padding 16px - font-size 10px - background var(--subNoteBg) - - &:not(.mini) - - @media (min-width 350px) - font-size 12px - - @media (min-width 500px) - font-size 14px - - @media (min-width 600px) - padding 24px 32px - - > .avatar - - @media (min-width 350px) - margin-right 10px - width 42px - height 42px - - @media (min-width 500px) - margin-right 14px - width 50px - height 50px - - &.smart - > .main - width 100% - - > header - align-items center - - > .avatar - flex-shrink 0 - display block - margin 0 8px 0 0 - width 38px - height 38px - border-radius 8px - - > .main - flex 1 - min-width 0 - - > .header - margin-bottom 2px - - > .body - > .cw - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - color var(--noteText) - - > .text - margin-right 8px - - > .content - > .text - margin 0 - padding 0 - color var(--subNoteText) - font-size calc(1em + var(--fontSize)) - - pre - max-height 120px - font-size 80% - -</style> diff --git a/src/client/app/mobile/views/components/note.vue b/src/client/app/mobile/views/components/note.vue deleted file mode 100644 index 01514f05fc..0000000000 --- a/src/client/app/mobile/views/components/note.vue +++ /dev/null @@ -1,302 +0,0 @@ -<template> -<div - class="note" - v-show="appearNote.deletedAt == null && !hideThisNote" - :tabindex="appearNote.deletedAt == null ? '-1' : null" - :class="{ renote: isRenote, smart: $store.state.device.postStyle == 'smart', mini: narrow }" - v-hotkey="keymap" -> - <x-sub v-for="note in conversation" :key="note.id" :note="note"/> - <div class="reply-to" v-if="appearNote.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)"> - <x-sub :note="appearNote.reply"/> - </div> - <mk-renote class="renote" v-if="isRenote" :note="note"/> - <article class="article"> - <mk-avatar class="avatar" :user="appearNote.user" v-if="$store.state.device.postStyle != 'smart'"/> - <div class="main"> - <mk-note-header class="header" :note="appearNote" :mini="true"/> - <div class="body" v-if="appearNote.deletedAt == null"> - <p v-if="appearNote.cw != null" class="cw"> - <mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" /> - <mk-cw-button v-model="showContent" :note="appearNote"/> - </p> - <div class="content" v-show="appearNote.cw == null || showContent"> - <div class="text"> - <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $t('private') }})</span> - <a class="reply" v-if="appearNote.reply"><fa icon="reply"/></a> - <mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/> - <a class="rp" v-if="appearNote.renote != null">RN:</a> - </div> - <div class="files" v-if="appearNote.files.length > 0"> - <mk-media-list :media-list="appearNote.files"/> - </div> - <mk-poll v-if="appearNote.poll" :note="appearNote" ref="pollViewer"/> - <mk-url-preview v-for="url in urls" :url="url" :key="url" :compact="true"/> - <a class="location" v-if="appearNote.geo" :href="`https://maps.google.com/maps?q=${appearNote.geo.coordinates[1]},${appearNote.geo.coordinates[0]}`" rel="noopener" target="_blank"><fa icon="map-marker-alt"/> {{ $t('location') }}</a> - <div class="renote" v-if="appearNote.renote"><mk-note-preview :note="appearNote.renote"/></div> - </div> - <span class="app" v-if="appearNote.app && $store.state.settings.showVia">via <b>{{ appearNote.app.name }}</b></span> - </div> - <footer v-if="appearNote.deletedAt == null" class="footer"> - <mk-reactions-viewer :note="appearNote" ref="reactionsViewer"/> - <button @click="reply()" class="button"> - <template v-if="appearNote.reply"><fa icon="reply-all"/></template> - <template v-else><fa icon="reply"/></template> - <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> - </button> - <button v-if="['public', 'home'].includes(appearNote.visibility)" @click="renote()" title="Renote" class="button"> - <fa icon="retweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> - </button> - <button v-else class="button"> - <fa icon="ban"/> - </button> - <button v-if="!isMyNote && appearNote.myReaction == null" class="button" @click="react()" ref="reactButton"> - <fa icon="plus"/> - </button> - <button v-if="!isMyNote && appearNote.myReaction != null" class="button reacted" @click="undoReact(appearNote)" ref="reactButton"> - <fa icon="minus"/> - </button> - <button class="button" @click="menu()" ref="menuButton"> - <fa icon="ellipsis-h"/> - </button> - </footer> - <div class="deleted" v-if="appearNote.deletedAt != null">{{ $t('deleted') }}</div> - </div> - </article> - <x-sub v-for="note in replies" :key="note.id" :note="note"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -import XSub from './note.sub.vue'; -import noteMixin from '../../../common/scripts/note-mixin'; -import noteSubscriber from '../../../common/scripts/note-subscriber'; - -export default Vue.extend({ - i18n: i18n('mobile/views/components/note.vue'), - components: { - XSub - }, - - mixins: [ - noteMixin({ - mobile: true - }), - noteSubscriber('note') - ], - - props: { - note: { - type: Object, - required: true - }, - detail: { - type: Boolean, - required: false, - default: false - }, - }, - - inject: { - narrow: { - default: false - } - }, - - data() { - return { - conversation: [], - replies: [] - }; - }, - - created() { - if (this.detail) { - this.$root.api('notes/children', { - noteId: this.appearNote.id, - limit: 30 - }).then(replies => { - this.replies = replies; - }); - - this.$root.api('notes/conversation', { - noteId: this.appearNote.replyId - }).then(conversation => { - this.conversation = conversation.reverse(); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.note - overflow hidden - font-size 13px - border-bottom solid var(--lineWidth) var(--faceDivider) - - &:last-of-type - border-bottom none - - &:not(.mini) - - @media (min-width 350px) - font-size 14px - - @media (min-width 500px) - font-size 16px - - > .article - @media (min-width 600px) - padding 32px 32px 22px - - > .avatar - @media (min-width 350px) - width 48px - height 48px - border-radius 6px - - @media (min-width 500px) - margin-right 16px - width 58px - height 58px - border-radius 8px - - > .main - > .header - @media (min-width 500px) - margin-bottom 2px - - > .body - @media (min-width 700px) - font-size 1.1em - - &.smart - > .article - > .main - > header - align-items center - margin-bottom 4px - - > .renote + .article - padding-top 8px - - > .article - display flex - padding 16px 16px 9px - - > .avatar - flex-shrink 0 - display block - margin 0 10px 8px 0 - width 42px - height 42px - border-radius 6px - //position -webkit-sticky - //position sticky - //top 62px - - > .main - flex 1 - min-width 0 - - > .body - > .cw - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - color var(--noteText) - - > .text - margin-right 8px - - > .content - - > .text - display block - margin 0 - padding 0 - overflow-wrap break-word - color var(--noteText) - font-size calc(1em + var(--fontSize)) - - > .reply - margin-right 8px - color var(--noteText) - - > .rp - margin-left 4px - font-style oblique - color var(--renoteText) - - .mk-url-preview - margin-top 8px - - > .files - > img - display block - max-width 100% - - > .location - margin 4px 0 - font-size 12px - color #ccc - - > .map - width 100% - height 200px - - &:empty - display none - - > .mk-poll - font-size 80% - - > .renote - margin 8px 0 - - > * - padding 16px - border dashed var(--lineWidth) var(--quoteBorder) - border-radius 8px - - > .app - font-size 12px - color #ccc - - > .footer - > .button - margin 0 - padding 8px - background transparent - border none - box-shadow none - font-size 1em - color var(--noteActions) - cursor pointer - - &:not(:last-child) - margin-right 28px - - &:hover - color var(--noteActionsHover) - - > .count - display inline - margin 0 0 0 8px - color var(--text) - opacity 0.7 - - &.reacted - color var(--primary) - - > .deleted - color var(--noteText) - opacity 0.7 - -</style> diff --git a/src/client/app/mobile/views/components/notes.vue b/src/client/app/mobile/views/components/notes.vue deleted file mode 100644 index 1a0cd5cc24..0000000000 --- a/src/client/app/mobile/views/components/notes.vue +++ /dev/null @@ -1,167 +0,0 @@ -<template> -<div class="ivaojijs" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> - <div class="empty" v-if="empty">{{ $t('@.no-notes') }}</div> - - <mk-error v-if="error" @retry="init()"/> - - <div class="placeholder" v-if="fetching"> - <template v-for="i in 10"> - <mk-note-skeleton :key="i"/> - </template> - </div> - - <!-- トランジションを有効にするとなぜかメモリリークする --> - <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="transition" tag="div"> - <template v-for="(note, i) in _notes"> - <mk-note :note="note" :key="note.id"/> - <p class="date" :key="note.id + '_date'" v-if="i != items.length - 1 && note._date != _notes[i + 1]._date"> - <span><fa icon="angle-up"/>{{ note._datetext }}</span> - <span><fa icon="angle-down"/>{{ _notes[i + 1]._datetext }}</span> - </p> - </template> - </component> - - <footer v-if="more"> - <button @click="fetchMore()" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> - <template v-if="!moreFetching">{{ $t('@.load-more') }}</template> - <template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template> - </button> - </footer> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import shouldMuteNote from '../../../common/scripts/should-mute-note'; -import paging from '../../../common/scripts/paging'; - -export default Vue.extend({ - i18n: i18n(), - - mixins: [ - paging({ - captureWindowScroll: true, - - onQueueChanged: (self, x) => { - if (x.length > 0) { - self.$store.commit('indicate', true); - } else { - self.$store.commit('indicate', false); - } - }, - - onPrepend: (self, note) => { - // 弾く - if (shouldMuteNote(self.$store.state.i, self.$store.state.settings, note)) return false; - - // タブが非表示またはスクロール位置が最上部ではないならタイトルで通知 - if (document.hidden || !self.isScrollTop()) { - self.$store.commit('pushBehindNote', note); - } - }, - - onInited: (self) => { - self.$emit('loaded'); - } - }), - ], - - props: { - pagination: { - required: true - }, - }, - - computed: { - _notes(): any[] { - return (this.items as any).map(item => { - const date = new Date(item.createdAt).getDate(); - const month = new Date(item.createdAt).getMonth() + 1; - item._date = date; - item._datetext = this.$t('@.month-and-day').replace('{month}', month.toString()).replace('{day}', date.toString()); - return item; - }); - } - }, -}); -</script> - -<style lang="stylus" scoped> -.ivaojijs - overflow hidden - background var(--face) - - &.round - border-radius 8px - - &.shadow - box-shadow 0 4px 16px rgba(#000, 0.1) - - @media (min-width 500px) - box-shadow 0 8px 32px rgba(#000, 0.1) - - > .empty - padding 16px - text-align center - color var(--text) - - .transition - .mk-notes-enter - .mk-notes-leave-to - opacity 0 - transform translateY(-30px) - - > * - transition transform .3s ease, opacity .3s ease - - > .date - display block - margin 0 - line-height 32px - text-align center - font-size 0.9em - color var(--dateDividerFg) - background var(--dateDividerBg) - border-bottom solid var(--lineWidth) var(--faceDivider) - - span - margin 0 16px - - [data-icon] - margin-right 8px - - > .placeholder - padding 16px - opacity 0.3 - - @media (min-width 500px) - padding 32px - - > .empty - margin 0 auto - padding 32px - max-width 400px - text-align center - color var(--text) - - > footer - text-align center - border-top solid var(--lineWidth) var(--faceDivider) - - &:empty - display none - - > button - margin 0 - padding 16px - width 100% - color var(--text) - - @media (min-width 500px) - padding 20px - - &:disabled - opacity 0.7 - -</style> diff --git a/src/client/app/mobile/views/components/notification-preview.vue b/src/client/app/mobile/views/components/notification-preview.vue deleted file mode 100644 index 8422c73420..0000000000 --- a/src/client/app/mobile/views/components/notification-preview.vue +++ /dev/null @@ -1,137 +0,0 @@ -<template> -<div class="mk-notification-preview" :class="notification.type"> - <template v-if="notification.type == 'reaction'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div class="text"> - <p><mk-reaction-icon :reaction="notification.reaction"/><mk-user-name :user="notification.user"/></p> - <p class="note-ref"><fa icon="quote-left"/>{{ getNoteSummary(notification.note) }}<fa icon="quote-right"/></p> - </div> - </template> - - <template v-if="notification.type == 'renote'"> - <mk-avatar class="avatar" :user="notification.note.user"/> - <div class="text"> - <p><fa icon="retweet"/><mk-user-name :user="notification.note.user"/></p> - <p class="note-ref"><fa icon="quote-left"/>{{ getNoteSummary(notification.note.renote) }}<fa icon="quote-right"/></p> - </div> - </template> - - <template v-if="notification.type == 'quote'"> - <mk-avatar class="avatar" :user="notification.note.user"/> - <div class="text"> - <p><fa icon="quote-left"/><mk-user-name :user="notification.note.user"/></p> - <p class="note-preview">{{ getNoteSummary(notification.note) }}</p> - </div> - </template> - - <template v-if="notification.type == 'follow'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div class="text"> - <p><fa icon="user-plus"/><mk-user-name :user="notification.user"/></p> - </div> - </template> - - <template v-if="notification.type == 'receiveFollowRequest'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div class="text"> - <p><fa icon="user-clock"/><mk-user-name :user="notification.user"/></p> - </div> - </template> - - <template v-if="notification.type == 'reply'"> - <mk-avatar class="avatar" :user="notification.note.user"/> - <div class="text"> - <p><fa icon="reply"/><mk-user-name :user="notification.note.user"/></p> - <p class="note-preview">{{ getNoteSummary(notification.note) }}</p> - </div> - </template> - - <template v-if="notification.type == 'mention'"> - <mk-avatar class="avatar" :user="notification.note.user"/> - <div class="text"> - <p><fa icon="at"/><mk-user-name :user="notification.note.user"/></p> - <p class="note-preview">{{ getNoteSummary(notification.note) }}</p> - </div> - </template> - - <template v-if="notification.type == 'pollVote'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div class="text"> - <p><fa icon="chart-pie"/><mk-user-name :user="notification.user"/></p> - <p class="note-ref"><fa icon="quote-left"/>{{ getNoteSummary(notification.note) }}<fa icon="quote-right"/></p> - </div> - </template> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import getNoteSummary from '../../../../../misc/get-note-summary'; - -export default Vue.extend({ - props: ['notification'], - data() { - return { - getNoteSummary - }; - } -}); -</script> - -<style lang="stylus" scoped> -.mk-notification-preview - margin 0 - padding 8px - color #fff - overflow-wrap break-word - - &:after - content "" - display block - clear both - - > .avatar - display block - float left - width 36px - height 36px - border-radius 6px - - > .text - float right - width calc(100% - 36px) - padding-left 8px - - p - margin 0 - - [data-icon], mk-reaction-icon - margin-right 4px - - .note-ref - - [data-icon] - font-size 1em - font-weight normal - font-style normal - display inline-block - margin-right 3px - - &.renote, &.quote - .text p [data-icon] - color #77B255 - - &.follow - .text p [data-icon] - color #53c7ce - - &.receiveFollowRequest - .text p [data-icon] - color #888 - - &.reply, &.mention - .text p [data-icon] - color #fff - -</style> - diff --git a/src/client/app/mobile/views/components/notification.vue b/src/client/app/mobile/views/components/notification.vue deleted file mode 100644 index 2defef4777..0000000000 --- a/src/client/app/mobile/views/components/notification.vue +++ /dev/null @@ -1,199 +0,0 @@ -<template> -<div class="mk-notification"> - <div class="notification reaction" v-if="notification.type == 'reaction'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div> - <header> - <mk-reaction-icon :reaction="notification.reaction"/> - <router-link class="name" :to="notification.user | userPage"><mk-user-name :user="notification.user"/></router-link> - <mk-time :time="notification.createdAt"/> - </header> - <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> - <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :custom-emojis="notification.note.emojis"/> - <fa icon="quote-right"/> - </router-link> - </div> - </div> - - <div class="notification renote" v-if="notification.type == 'renote'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div> - <header> - <fa icon="retweet"/> - <router-link class="name" :to="notification.user | userPage"><mk-user-name :user="notification.user"/></router-link> - <mk-time :time="notification.createdAt"/> - </header> - <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note.renote)"> - <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="true" :custom-emojis="notification.note.renote.emojis"/> - <fa icon="quote-right"/> - </router-link> - </div> - </div> - - <div class="notification follow" v-if="notification.type == 'follow'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div> - <header> - <fa icon="user-plus"/> - <router-link class="name" :to="notification.user | userPage"><mk-user-name :user="notification.user"/></router-link> - <mk-time :time="notification.createdAt"/> - </header> - </div> - </div> - - <div class="notification followRequest" v-if="notification.type == 'receiveFollowRequest'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div> - <header> - <fa icon="user-clock"/> - <router-link class="name" :to="notification.user | userPage"><mk-user-name :user="notification.user"/></router-link> - <mk-time :time="notification.createdAt"/> - </header> - </div> - </div> - - <div class="notification pollVote" v-if="notification.type == 'pollVote'"> - <mk-avatar class="avatar" :user="notification.user"/> - <div> - <header> - <fa icon="chart-pie"/> - <router-link class="name" :to="notification.user | userPage"><mk-user-name :user="notification.user"/></router-link> - <mk-time :time="notification.createdAt"/> - </header> - <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> - <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :custom-emojis="notification.note.emojis"/> - <fa icon="quote-right"/> - </router-link> - </div> - </div> - - <template v-if="notification.type == 'quote'"> - <mk-note :note="notification.note"/> - </template> - - <template v-if="notification.type == 'reply'"> - <mk-note :note="notification.note"/> - </template> - - <template v-if="notification.type == 'mention'"> - <mk-note :note="notification.note"/> - </template> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import getNoteSummary from '../../../../../misc/get-note-summary'; - -export default Vue.extend({ - props: ['notification'], - data() { - return { - getNoteSummary - }; - }, -}); -</script> - -<style lang="stylus" scoped> -.mk-notification - - &.wide - > .notification - @media (min-width 350px) - font-size 14px - - @media (min-width 500px) - font-size 16px - - @media (min-width 600px) - padding 24px 32px - - > .avatar - @media (min-width 500px) - width 42px - height 42px - - > div - @media (min-width 500px) - width calc(100% - 42px) - - > .notification - padding 16px - font-size 12px - overflow-wrap break-word - - &:after - content "" - display block - clear both - - > .avatar - display block - float left - width 36px - height 36px - border-radius 6px - - > div - float right - width calc(100% - 36px) - padding-left 8px - - > header - display flex - align-items baseline - white-space nowrap - - [data-icon], .mk-reaction-icon - margin-right 4px - - > .name - text-overflow ellipsis - white-space nowrap - min-width 0 - overflow hidden - - > .mk-time - margin-left auto - color var(--noteHeaderInfo) - font-size 0.9em - - > .note-preview - color var(--noteText) - - > .note-ref - color var(--noteText) - display inline-block - width: 100% - overflow hidden - white-space nowrap - text-overflow ellipsis - - [data-icon] - font-size 1em - font-weight normal - font-style normal - display inline-block - margin-right 3px - - &.reaction - > div > header - align-items normal - - &.renote - > div > header [data-icon] - color #77B255 - - &.follow - > div > header [data-icon] - color #53c7ce - - &.receiveFollowRequest - > div > header [data-icon] - color #888 - -</style> diff --git a/src/client/app/mobile/views/components/notifications.vue b/src/client/app/mobile/views/components/notifications.vue deleted file mode 100644 index ca6a8beca3..0000000000 --- a/src/client/app/mobile/views/components/notifications.vue +++ /dev/null @@ -1,167 +0,0 @@ -<template> -<div class="mk-notifications"> - <div class="placeholder" v-if="fetching"> - <template v-for="i in 10"> - <mk-note-skeleton :key="i"/> - </template> - </div> - - <!-- トランジションを有効にするとなぜかメモリリークする --> - <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications" tag="div"> - <template v-for="(notification, i) in _notifications"> - <mk-notification :notification="notification" :key="notification.id" :class="{ wide: wide }"/> - <p class="date" :key="notification.id + '_date'" v-if="i != items.length - 1 && notification._date != _notifications[i + 1]._date"> - <span><fa icon="angle-up"/>{{ notification._datetext }}</span> - <span><fa icon="angle-down"/>{{ _notifications[i + 1]._datetext }}</span> - </p> - </template> - </component> - - <button class="more" v-if="more" @click="fetchMore" :disabled="moreFetching"> - <template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template> - {{ moreFetching ? $t('@.loading') : $t('@.load-more') }} - </button> - - <p class="empty" v-if="empty">{{ $t('empty') }}</p> - - <mk-error v-if="error" @retry="init()"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import paging from '../../../common/scripts/paging'; - -export default Vue.extend({ - i18n: i18n('mobile/views/components/notifications.vue'), - - mixins: [ - paging({ - beforeInit: (self) => { - self.$emit('beforeInit'); - }, - onInited: (self) => { - self.$emit('inited'); - } - }), - ], - - props: { - type: { - type: String, - required: false - }, - wide: { - type: Boolean, - required: false, - default: false - } - }, - - data() { - return { - connection: null, - pagination: { - endpoint: 'i/notifications', - limit: 15, - params: () => ({ - includeTypes: this.type ? [this.type] : undefined - }) - } - }; - }, - - computed: { - _notifications(): any[] { - return (this.items as any).map(notification => { - const date = new Date(notification.createdAt).getDate(); - const month = new Date(notification.createdAt).getMonth() + 1; - notification._date = date; - notification._datetext = this.$t('@.month-and-day').replace('{month}', month.toString()).replace('{day}', date.toString()); - return notification; - }); - } - }, - - watch: { - type() { - this.reload(); - } - }, - - mounted() { - this.connection = this.$root.stream.useSharedConnection('main'); - this.connection.on('notification', this.onNotification); - }, - - beforeDestroy() { - this.connection.dispose(); - }, - - methods: { - onNotification(notification) { - // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない - this.$root.stream.send('readNotification', { - id: notification.id - }); - - this.prepend(notification); - }, - } -}); -</script> - -<style lang="stylus" scoped> -.mk-notifications - .transition - .mk-notifications-enter - .mk-notifications-leave-to - opacity 0 - transform translateY(-30px) - - > * - transition transform .3s ease, opacity .3s ease - - > .notifications - - > .mk-notification:not(:last-child) - border-bottom solid var(--lineWidth) var(--faceDivider) - - > .date - display block - margin 0 - line-height 32px - text-align center - font-size 0.8em - color var(--dateDividerFg) - background var(--dateDividerBg) - border-bottom solid var(--lineWidth) var(--faceDivider) - - span - margin 0 16px - - [data-icon] - margin-right 8px - - > .more - display block - width 100% - padding 16px - color var(--text) - border-top solid var(--lineWidth) rgba(#000, 0.05) - - > [data-icon] - margin-right 4px - - > .empty - margin 0 - padding 16px - text-align center - color var(--text) - - > .placeholder - padding 32px - opacity 0.3 - -</style> diff --git a/src/client/app/mobile/views/components/notify.vue b/src/client/app/mobile/views/components/notify.vue deleted file mode 100644 index c6e1df0fde..0000000000 --- a/src/client/app/mobile/views/components/notify.vue +++ /dev/null @@ -1,73 +0,0 @@ -<template> -<div class="mk-notify" :class="pos"> - <div> - <mk-notification-preview :notification="notification"/> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import anime from 'animejs'; - -export default Vue.extend({ - props: ['notification'], - computed: { - pos() { - return this.$store.state.device.mobileNotificationPosition; - } - }, - mounted() { - this.$nextTick(() => { - anime({ - targets: this.$el, - [this.pos]: '0px', - duration: 500, - easing: 'easeOutQuad' - }); - - setTimeout(() => { - anime({ - targets: this.$el, - [this.pos]: `-${this.$el.offsetHeight}px`, - duration: 500, - easing: 'easeOutQuad', - complete: () => this.destroyDom() - }); - }, 6000); - }); - } -}); -</script> - -<style lang="stylus" scoped> -.mk-notify - $height = 78px - - position fixed - z-index 10000 - left 0 - right 0 - width 100% - max-width 500px - height $height - margin 0 auto - padding 8px - pointer-events none - font-size 80% - - &.bottom - bottom -($height) - - &.top - top -($height) - - > div - height 100% - -webkit-backdrop-filter blur(2px) - backdrop-filter blur(2px) - background-color rgba(#000, 0.5) - border-radius 7px - overflow hidden - -</style> diff --git a/src/client/app/mobile/views/components/post-form-dialog.vue b/src/client/app/mobile/views/components/post-form-dialog.vue deleted file mode 100644 index 4ae79dbd7b..0000000000 --- a/src/client/app/mobile/views/components/post-form-dialog.vue +++ /dev/null @@ -1,120 +0,0 @@ -<template> -<ui-modal - ref="modal" - :close-on-bg-click="false" - :close-anime-duration="300" - @before-close="onBeforeClose"> - <div class="main" ref="main"> - <x-post-form ref="form" - :reply="reply" - :renote="renote" - :mention="mention" - :initial-text="initialText" - :initial-note="initialNote" - :instant="instant" - @posted="onPosted" - @cancel="onCanceled"/> - </div> -</ui-modal> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import anime from 'animejs'; -import XPostForm from './post-form.vue'; - -export default Vue.extend({ - components: { - XPostForm - }, - - props: { - reply: { - type: Object, - required: false - }, - renote: { - type: Object, - required: false - }, - mention: { - type: Object, - required: false - }, - initialText: { - type: String, - required: false - }, - initialNote: { - type: Object, - required: false - }, - instant: { - type: Boolean, - required: false, - default: false - } - }, - - mounted() { - this.$nextTick(() => { - anime({ - targets: this.$refs.main, - opacity: 1, - translateY: [-16, 0], - duration: 300, - easing: 'easeOutQuad' - }); - }); - }, - - methods: { - focus() { - this.$refs.form.focus(); - }, - - onBeforeClose() { - (this.$refs.main as any).style.pointerEvents = 'none'; - - anime({ - targets: this.$refs.main, - opacity: 0, - translateY: 16, - duration: 300, - easing: 'easeOutQuad' - }); - }, - - close() { - (this.$refs.modal as any).close(); - }, - - onPosted() { - this.$emit('posted'); - this.close(); - }, - - onCanceled() { - this.$emit('cancel'); - this.close(); - } - } -}); -</script> - -<style lang="stylus" scoped> - -.main - display block - position fixed - z-index 10000 - top 0 - left 0 - right 0 - height 100% - overflow auto - margin 0 auto 0 auto - opacity 0 - transform translateY(-16px) - -</style> diff --git a/src/client/app/mobile/views/components/post-form.vue b/src/client/app/mobile/views/components/post-form.vue deleted file mode 100644 index 38c6a42dd5..0000000000 --- a/src/client/app/mobile/views/components/post-form.vue +++ /dev/null @@ -1,244 +0,0 @@ -<template> -<div class="gafaadew"> - <div class="form" - @dragover.stop="onDragover" - @dragenter="onDragenter" - @dragleave="onDragleave" - @drop.stop="onDrop" - > - <header> - <button class="cancel" @click="cancel"><fa icon="times"/></button> - <div> - <span class="text-count" :class="{ over: trimmedLength(text) > maxNoteTextLength }">{{ maxNoteTextLength - trimmedLength(text) }}</span> - <span class="geo" v-if="geo"><fa icon="map-marker-alt"/></span> - <button class="submit" :disabled="!canPost" @click="post">{{ submitText }}</button> - </div> - </header> - <div class="form"> - <mk-note-preview class="preview" v-if="reply" :note="reply"/> - <mk-note-preview class="preview" v-if="renote" :note="renote"/> - <div class="with-quote" v-if="quoteId"><fa icon="quote-left"/> {{ $t('@.post-form.quote-attached') }}<button @click="quoteId = null"><fa icon="times"/></button></div> - <div v-if="visibility === 'specified'" class="to-specified"> - <fa icon="envelope"/> {{ $t('@.post-form.specified-recipient') }} - <div class="visibleUsers"> - <span v-for="u in visibleUsers"> - <mk-user-name :user="u"/> - <button @click="removeVisibleUser(u)"><fa icon="times"/></button> - </span> - <button @click="addVisibleUser">{{ $t('@.post-form.add-visible-user') }}</button> - </div> - </div> - <div class="local-only" v-if="localOnly === true"><fa icon="heart"/> {{ $t('@.post-form.local-only-message') }}</div> - <input v-show="useCw" ref="cw" v-model="cw" :placeholder="$t('@.post-form.cw-placeholder')" v-autocomplete="{ model: 'cw' }"> - <textarea v-model="text" ref="text" :disabled="posting" :placeholder="placeholder" v-autocomplete="{ model: 'text' }" @paste="onPaste"></textarea> - <x-post-form-attaches class="attaches" :files="files"/> - <x-poll-editor v-if="poll" ref="poll" @destroyed="poll = false" @updated="onPollUpdate()"/> - <mk-uploader ref="uploader" @uploaded="attachMedia" @change="onChangeUploadings"/> - <footer> - <button class="upload" @click="chooseFile"><fa icon="upload"/></button> - <button class="drive" @click="chooseFileFromDrive"><fa icon="cloud"/></button> - <button class="kao" @click="kao"><fa :icon="['far', 'smile']"/></button> - <button class="poll" @click="poll = true"><fa icon="chart-pie"/></button> - <button class="poll" @click="useCw = !useCw"><fa :icon="['far', 'eye-slash']"/></button> - <button class="geo" @click="geo ? removeGeo() : setGeo()"><fa icon="map-marker-alt"/></button> - <button class="visibility" @click="setVisibility" ref="visibilityButton"> - <span v-if="visibility === 'public'"><fa icon="globe"/></span> - <span v-if="visibility === 'home'"><fa icon="home"/></span> - <span v-if="visibility === 'followers'"><fa icon="unlock"/></span> - <span v-if="visibility === 'specified'"><fa icon="envelope"/></span> - </button> - </footer> - <input ref="file" class="file" type="file" multiple="multiple" @change="onChangeFile"/> - </div> - </div> - <div class="hashtags" v-if="recentHashtags.length > 0 && $store.state.settings.suggestRecentHashtags"> - <a v-for="tag in recentHashtags.slice(0, 5)" @click="addTag(tag)">#{{ tag }}</a> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import form from '../../../common/scripts/post-form'; - -export default Vue.extend({ - i18n: i18n(), - - mixins: [ - form({ - mobile: true - }), - ], - - methods: { - cancel() { - this.$emit('cancel'); - }, - } -}); -</script> - -<style lang="stylus" scoped> -.gafaadew - max-width 500px - width calc(100% - 16px) - margin 8px auto - - @media (min-width 500px) - margin 16px auto - width calc(100% - 32px) - - > .form - box-shadow 0 8px 32px rgba(#000, 0.1) - - @media (min-width 600px) - margin 32px auto - - > .form - background var(--face) - border-radius 8px - box-shadow 0 0 2px rgba(#000, 0.1) - - > header - z-index 1000 - height 50px - box-shadow 0 1px 0 0 var(--mobilePostFormDivider) - - > .cancel - padding 0 - width 50px - line-height 50px - font-size 24px - color var(--text) - - > div - position absolute - top 0 - right 0 - color var(--text) - - > .text-count - line-height 50px - - > .geo - margin 0 8px - line-height 50px - - > .submit - margin 8px - padding 0 16px - line-height 34px - vertical-align bottom - color var(--primaryForeground) - background var(--primary) - border-radius 4px - - &:disabled - opacity 0.7 - - > .form - max-width 500px - margin 0 auto - - > .preview - padding 16px - - > .with-quote - margin 0 0 8px 0 - color var(--primary) - - > button - padding 4px 8px - color var(--primaryAlpha04) - - &:hover - color var(--primaryAlpha06) - - &:active - color var(--primaryDarken30) - - > .to-specified - margin 0 0 8px 0 - color var(--primary) - - > .visibleUsers - display inline - top -1px - font-size 14px - - > span - margin-left 14px - - > button - padding 4px 8px - color var(--primaryAlpha04) - - &:hover - color var(--primaryAlpha06) - - &:active - color var(--primaryDarken30) - - > .local-only - margin 0 0 8px 0 - color var(--primary) - - > input - z-index 1 - - > input - > textarea - display block - padding 12px - margin 0 - width 100% - font-size 16px - color var(--inputText) - background var(--mobilePostFormTextareaBg) - border none - border-radius 0 - box-shadow 0 1px 0 0 var(--mobilePostFormDivider) - - &:disabled - opacity 0.5 - - > textarea - max-width 100% - min-width 100% - min-height 80px - - > .mk-uploader - margin 8px 0 0 0 - padding 8px - - > .file - display none - - > footer - white-space nowrap - overflow auto - -webkit-overflow-scrolling touch - overflow-scrolling touch - - > * - display inline-block - padding 0 - margin 0 - width 48px - height 48px - font-size 20px - color var(--mobilePostFormButton) - background transparent - outline none - border none - border-radius 0 - box-shadow none - - > .hashtags - margin 8px - - > * - margin-right 8px - -</style> diff --git a/src/client/app/mobile/views/components/sub-note-content.vue b/src/client/app/mobile/views/components/sub-note-content.vue deleted file mode 100644 index 66dbb90ebb..0000000000 --- a/src/client/app/mobile/views/components/sub-note-content.vue +++ /dev/null @@ -1,47 +0,0 @@ -<template> -<div class="mk-sub-note-content"> - <div class="body"> - <span v-if="note.isHidden" style="opacity: 0.5">({{ $t('private') }})</span> - <span v-if="note.deletedAt" style="opacity: 0.5">({{ $t('deleted') }})</span> - <a class="reply" v-if="note.replyId"><fa icon="reply"/></a> - <mfm v-if="note.text" :text="note.text" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis"/> - <a class="rp" v-if="note.renoteId">RN: ...</a> - </div> - <details v-if="note.files.length > 0"> - <summary>({{ $t('media-count').replace('{}', note.files.length) }})</summary> - <mk-media-list :media-list="note.files"/> - </details> - <details v-if="note.poll"> - <summary>{{ $t('poll') }}</summary> - <mk-poll :note="note"/> - </details> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -export default Vue.extend({ - i18n: i18n('mobile/views/components/sub-note-content.vue'), - props: ['note'] -}); -</script> - -<style lang="stylus" scoped> -.mk-sub-note-content - overflow-wrap break-word - - > .body - > .reply - margin-right 6px - color #717171 - - > .rp - margin-left 4px - font-style oblique - color var(--renoteText) - - mk-poll - font-size 80% - -</style> diff --git a/src/client/app/mobile/views/components/ui-container.vue b/src/client/app/mobile/views/components/ui-container.vue deleted file mode 100644 index 08af7035f9..0000000000 --- a/src/client/app/mobile/views/components/ui-container.vue +++ /dev/null @@ -1,127 +0,0 @@ -<template> -<div class="ukygtjoj" :class="{ naked, inNakedDeckColumn, hideHeader: !showHeader, shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> - <header v-if="showHeader" @click="() => showBody = !showBody"> - <div class="title"><slot name="header"></slot></div> - <slot name="func"></slot> - <button v-if="bodyTogglable"> - <template v-if="showBody"><fa icon="angle-up"/></template> - <template v-else><fa icon="angle-down"/></template> - </button> - </header> - <div v-show="showBody"> - <slot></slot> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - props: { - showHeader: { - type: Boolean, - default: true - }, - naked: { - type: Boolean, - default: false - }, - bodyTogglable: { - type: Boolean, - default: false - }, - expanded: { - type: Boolean, - default: true - }, - }, - inject: { - inNakedDeckColumn: { - default: false - } - }, - data() { - return { - showBody: this.expanded - }; - }, - methods: { - toggleContent(show: boolean) { - if (!this.bodyTogglable) return; - this.showBody = show; - } - } -}); -</script> - -<style lang="stylus" scoped> -.ukygtjoj - overflow hidden - - &:not(.inNakedDeckColumn) - background var(--face) - - &.round - border-radius 8px - - &.shadow - box-shadow 0 4px 16px rgba(#000, 0.1) - - & + .ukygtjoj - margin-top 16px - - @media (max-width 500px) - margin-top 8px - - &.naked - background transparent !important - box-shadow none !important - - > header - > .title - margin 0 - padding 8px 10px - font-size 15px - font-weight normal - color var(--faceHeaderText) - background var(--faceHeader) - - > [data-icon] - margin-right 6px - - &:empty - display none - - > button - position absolute - z-index 2 - top 0 - right 0 - padding 0 - width 42px - height 100% - font-size 15px - color var(--faceTextButton) - - > div - color var(--text) - - &.inNakedDeckColumn - background var(--face) - - > header - margin 0 - padding 8px 16px - font-size 12px - color var(--text) - background var(--deckColumnBg) - - > button - position absolute - top 0 - right 8px - padding 8px 6px - font-size 14px - color var(--text) - -</style> diff --git a/src/client/app/mobile/views/components/ui.header.vue b/src/client/app/mobile/views/components/ui.header.vue deleted file mode 100644 index f20f64e7ff..0000000000 --- a/src/client/app/mobile/views/components/ui.header.vue +++ /dev/null @@ -1,142 +0,0 @@ -<template> -<div class="header" ref="root" :class="{ shadow: $store.state.device.useShadow }"> - <div class="main" ref="main"> - <div class="backdrop"></div> - <div class="content" ref="mainContainer"> - <button class="nav" @click="$parent.isDrawerOpening = true"><fa icon="bars"/></button> - <i v-if="$parent.indicate" class="circle"><fa icon="circle"/></i> - <h1> - <slot>{{ $root.instanceName }}</slot> - </h1> - <slot name="func"></slot> - </div> - </div> - <div class="indicator" v-show="$store.state.indicate"></div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { env } from '../../../config'; - -export default Vue.extend({ - i18n: i18n(), - props: ['func'], - - data() { - return { - env: env - }; - }, - - mounted() { - this.$store.commit('setUiHeaderHeight', 48); - }, -}); -</script> - -<style lang="stylus" scoped> -.header - $height = 48px - - position fixed - top 0 - left -8px - z-index 1024 - width calc(100% + 16px) - padding 0 8px - - &.shadow - box-shadow 0 0 8px rgba(0, 0, 0, 0.25) - - &, * - user-select none - - > .indicator - height 3px - background var(--primary) - - > .warn - display block - margin 0 - padding 4px - text-align center - font-size 12px - background #f00 - color #fff - - > .main - color var(--mobileHeaderFg) - - > .backdrop - position absolute - top 0 - z-index 1000 - width 100% - height $height - -webkit-backdrop-filter blur(12px) - backdrop-filter blur(12px) - background-color var(--mobileHeaderBg) - - > .content - z-index 1001 - - > h1 - display block - margin 0 auto - padding 0 - width 100% - max-width calc(100% - 112px) - text-align center - font-size 1.1em - font-weight normal - line-height $height - white-space nowrap - overflow hidden - text-overflow ellipsis - - > img - display inline-block - vertical-align bottom - width ($height - 16px) - height ($height - 16px) - margin 8px - border-radius 6px - - > .nav - display block - position absolute - top 0 - left 0 - padding 0 - width $height - font-size 1.4em - line-height $height - border-right solid 1px rgba(#000, 0.1) - - > [data-icon] - transition all 0.2s ease - - > i.circle - position absolute - top 8px - left 8px - pointer-events none - font-size 10px - color var(--notificationIndicator) - - > button:last-child - display block - position absolute - top 0 - right 0 - padding 0 - width $height - text-align center - font-size 1.4em - color inherit - line-height $height - border-left solid 1px rgba(#000, 0.1) - -</style> diff --git a/src/client/app/mobile/views/components/ui.nav.vue b/src/client/app/mobile/views/components/ui.nav.vue deleted file mode 100644 index db250ec6f8..0000000000 --- a/src/client/app/mobile/views/components/ui.nav.vue +++ /dev/null @@ -1,346 +0,0 @@ -<template> -<div class="fquwcbxs"> - <transition name="back"> - <div class="backdrop" - v-if="isOpen" - @click="$parent.isDrawerOpening = false" - @touchstart="$parent.isDrawerOpening = false" - ></div> - </transition> - <transition name="nav"> - <div class="body" :class="{ notifications: showNotifications }" v-if="isOpen"> - <div class="nav" v-show="!showNotifications"> - <router-link class="me" v-if="$store.getters.isSignedIn" :to="`/@${$store.state.i.username}`"> - <img class="avatar" :src="$store.state.i.avatarUrl" alt="avatar"/> - <p class="name"><mk-user-name :user="$store.state.i"/></p> - </router-link> - <div class="links"> - <ul> - <li><router-link to="/" :data-active="$route.name == 'index'"><i><fa icon="home" fixed-width/></i>{{ $t('timeline') }}<i><fa icon="angle-right"/></i></router-link></li> - <li v-if="$store.state.device.enableMobileQuickNotificationView"><p @click="showNotifications = true"><i><fa :icon="faBell" fixed-width/></i>{{ $t('notifications') }}<i v-if="hasUnreadNotification" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></p></li> - <li v-else><router-link to="/i/notifications" :data-active="$route.name == 'notifications'"><i><fa :icon="faBell" fixed-width/></i>{{ $t('notifications') }}<i v-if="hasUnreadNotification" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li> - <li><router-link to="/i/messaging" :data-active="$route.name == 'messaging'"><i><fa :icon="['far', 'comments']" fixed-width/></i>{{ $t('@.messaging') }}<i v-if="hasUnreadMessagingMessage" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li> - <li v-if="$store.getters.isSignedIn && ($store.state.i.isLocked || $store.state.i.carefulBot)"><router-link to="/i/follow-requests" :data-active="$route.name == 'follow-requests'"><i><fa :icon="['far', 'envelope']" fixed-width/></i>{{ $t('follow-requests') }}<i v-if="$store.getters.isSignedIn && $store.state.i.pendingReceivedFollowRequestsCount" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li> - <li><router-link to="/featured" :data-active="$route.name == 'featured'"><i><fa :icon="faNewspaper" fixed-width/></i>{{ $t('@.featured-notes') }}<i><fa icon="angle-right"/></i></router-link></li> - <li><router-link to="/explore" :data-active="$route.name == 'explore' || $route.name == 'explore-tag'"><i><fa :icon="faHashtag" fixed-width/></i>{{ $t('@.explore') }}<i><fa icon="angle-right"/></i></router-link></li> - <li><router-link to="/games/reversi" :data-active="$route.name == 'reversi'"><i><fa icon="gamepad" fixed-width/></i>{{ $t('game') }}<i v-if="hasGameInvitation" class="circle"><fa icon="circle"/></i><i><fa icon="angle-right"/></i></router-link></li> - </ul> - <ul> - <li><router-link to="/i/widgets" :data-active="$route.name == 'widgets'"><i><fa :icon="['far', 'calendar-alt']" fixed-width/></i>{{ $t('widgets') }}<i><fa icon="angle-right"/></i></router-link></li> - <li><router-link to="/i/favorites" :data-active="$route.name == 'favorites'"><i><fa icon="star" fixed-width/></i>{{ $t('@.favorites') }}<i><fa icon="angle-right"/></i></router-link></li> - <li><router-link to="/i/lists" :data-active="$route.name == 'user-lists'"><i><fa icon="list" fixed-width/></i>{{ $t('user-lists') }}<i><fa icon="angle-right"/></i></router-link></li> - <li><router-link to="/i/groups" :data-active="$route.name == 'user-groups'"><i><fa :icon="faUsers" fixed-width/></i>{{ $t('user-groups') }}<i><fa icon="angle-right"/></i></router-link></li> - <li><router-link to="/i/drive" :data-active="$route.name == 'drive'"><i><fa icon="cloud" fixed-width/></i>{{ $t('@.drive') }}<i><fa icon="angle-right"/></i></router-link></li> - <li><router-link to="/i/pages" :data-active="$route.name == 'pages'"><i><fa :icon="faStickyNote" fixed-width/></i>{{ $t('@.pages') }}<i><fa icon="angle-right"/></i></router-link></li> - </ul> - <ul> - <li><a @click="search"><i><fa icon="search" fixed-width/></i>{{ $t('search') }}<i><fa icon="angle-right"/></i></a></li> - <li><router-link to="/i/settings" :data-active="$route.name == 'settings'"><i><fa icon="cog" fixed-width/></i>{{ $t('@.settings') }}<i><fa icon="angle-right"/></i></router-link></li> - <li v-if="$store.getters.isSignedIn && ($store.state.i.isAdmin || $store.state.i.isModerator)"><a href="/admin"><i><fa icon="terminal" fixed-width/></i><span>{{ $t('admin') }}</span><i><fa icon="angle-right"/></i></a></li> - </ul> - <ul> - <li @click="toggleDeckMode"><p><i><fa :icon="$store.state.device.inDeckMode ? faHome : faColumns" fixed-width/></i><span>{{ $store.state.device.inDeckMode ? $t('@.home') : $t('@.deck') }}</span></p></li> - <li @click="dark"><p><i><fa :icon="$store.state.device.darkmode ? faSun : faMoon" fixed-width/></i><span>{{ $store.state.device.darkmode ? $t('@.turn-off-darkmode') : $t('@.turn-on-darkmode') }}</span></p></li> - </ul> - </div> - <div class="announcements" v-if="announcements && announcements.length > 0"> - <article v-for="announcement in announcements"> - <span v-html="announcement.title" class="title"></span> - <div><mfm :text="announcement.text"/></div> - <img v-if="announcement.image" :src="announcement.image" alt="" style="display: block; max-height: 120px; max-width: 100%;"/> - </article> - </div> - <a :href="aboutUrl"><p class="about">{{ $t('about') }}</p></a> - </div> - <div class="notifications" v-if="showNotifications"> - <header> - <button @click="showNotifications = false"><fa icon="times"/></button> - <i v-if="hasUnreadNotification" class="circle"><fa icon="circle"/></i> - </header> - <mk-notifications/> - </div> - </div> - </transition> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { lang } from '../../../config'; -import { faNewspaper, faHashtag, faHome, faColumns, faUsers } from '@fortawesome/free-solid-svg-icons'; -import { faMoon, faSun, faStickyNote, faBell } from '@fortawesome/free-regular-svg-icons'; -import { search } from '../../../common/scripts/search'; - -export default Vue.extend({ - i18n: i18n('mobile/views/components/ui.nav.vue'), - - props: ['isOpen'], - - provide: { - narrow: true - }, - - data() { - return { - hasGameInvitation: false, - connection: null, - aboutUrl: `/docs/${lang}/about`, - announcements: [], - searching: false, - showNotifications: false, - faNewspaper, faHashtag, faMoon, faSun, faHome, faColumns, faStickyNote, faUsers, faBell, - }; - }, - - computed: { - hasUnreadNotification(): boolean { - return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadNotification; - }, - - hasUnreadMessagingMessage(): boolean { - return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadMessagingMessage; - } - }, - - watch: { - isOpen() { - this.showNotifications = false; - } - }, - - mounted() { - this.$root.getMeta().then(meta => { - this.announcements = meta.announcements; - }); - - if (this.$store.getters.isSignedIn) { - this.connection = this.$root.stream.useSharedConnection('main'); - - this.connection.on('reversiInvited', this.onReversiInvited); - this.connection.on('reversiNoInvites', this.onReversiNoInvites); - } - }, - - beforeDestroy() { - if (this.$store.getters.isSignedIn) { - this.connection.dispose(); - } - }, - - methods: { - search() { - if (this.searching) return; - - this.$root.dialog({ - title: this.$t('search'), - input: true - }).then(async ({ canceled, result: query }) => { - if (canceled) return; - - this.searching = true; - search(this, query).finally(() => { - this.searching = false; - }); - }); - }, - - onReversiInvited() { - this.hasGameInvitation = true; - }, - - onReversiNoInvites() { - this.hasGameInvitation = false; - }, - - dark() { - this.$store.commit('device/set', { - key: 'darkmode', - value: !this.$store.state.device.darkmode - }); - }, - - toggleDeckMode() { - this.$store.commit('device/set', { key: 'deckMode', value: !this.$store.state.device.inDeckMode }); - location.replace('/'); - }, - } -}); -</script> - -<style lang="stylus" scoped> -.fquwcbxs - $color = var(--text) - - .backdrop - position fixed - top 0 - left 0 - z-index 1025 - width 100% - height 100% - background var(--mobileNavBackdrop) - - .body - position fixed - top 0 - left 0 - z-index 1026 - width 240px - height 100% - overflow auto - -webkit-overflow-scrolling touch - background var(--secondary) - font-size 15px - - &.notifications - width 330px - - > .notifications - padding-top 42px - - > header - position fixed - top 0 - left 0 - z-index 1000 - width 330px - line-height 42px - background var(--secondary) - - > button - display block - padding 0 14px - font-size 20px - line-height 42px - color var(--text) - - > i - position absolute - top 0 - right 16px - font-size 12px - color var(--notificationIndicator) - - > .nav - - > .me - display block - margin 0 - padding 16px - - .avatar - display inline - max-width 64px - border-radius 32px - vertical-align middle - - .name - display block - margin 0 16px - position absolute - top 0 - left 80px - padding 0 - width calc(100% - 112px) - color $color - line-height 96px - overflow hidden - text-overflow ellipsis - white-space nowrap - - ul - display block - margin 16px 0 - padding 0 - list-style none - - &:first-child - margin-top 0 - - &:last-child - margin-bottom 0 - - > li - display block - font-size 1em - line-height 1em - - a, p - display block - margin 0 - padding 0 20px - line-height 3rem - line-height calc(1rem + 30px) - color $color - text-decoration none - - &[data-active] - color var(--primaryForeground) - background var(--primary) - - > i:last-child - color var(--primaryForeground) - - > i:first-child - margin-right 0.5em - width 20px - text-align center - - > i.circle - margin-left 6px - font-size 10px - color var(--notificationIndicator) - - > i:last-child - position absolute - top 0 - right 0 - padding 0 20px - font-size 1.2em - line-height calc(1rem + 30px) - color $color - opacity 0.5 - - .announcements - > article - background var(--mobileAnnouncement) - color var(--mobileAnnouncementFg) - padding 16px - margin 8px 0 - font-size 12px - - > .title - font-weight bold - - .about - margin 0 0 8px 0 - padding 1em 0 - text-align center - font-size 0.8em - color $color - opacity 0.5 - -.nav-enter-active, -.nav-leave-active { - opacity: 1; - transform: translateX(0); - transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.nav-enter, -.nav-leave-active { - opacity: 0; - transform: translateX(-240px); -} - -.back-enter-active, -.back-leave-active { - opacity: 1; - transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.back-enter, -.back-leave-active { - opacity: 0; -} - -</style> diff --git a/src/client/app/mobile/views/components/ui.vue b/src/client/app/mobile/views/components/ui.vue deleted file mode 100644 index 05c886a497..0000000000 --- a/src/client/app/mobile/views/components/ui.vue +++ /dev/null @@ -1,136 +0,0 @@ -<template> -<div class="mk-ui" :class="{ deck: $store.state.device.inDeckMode }"> - <x-header v-if="!$store.state.device.inDeckMode"> - <template #func><slot name="func"></slot></template> - <slot name="header"></slot> - </x-header> - <x-nav :is-open="isDrawerOpening"/> - <div class="content"> - <slot></slot> - </div> - <mk-stream-indicator v-if="$store.getters.isSignedIn"/> - <button class="nav button" v-if="$store.state.device.inDeckMode" @click="isDrawerOpening = !isDrawerOpening"><fa icon="bars"/><i v-if="indicate"><fa icon="circle"/></i></button> - <button class="post button" v-if="$store.state.device.inDeckMode" @click="$post()"><fa icon="pencil-alt"/></button> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import MkNotify from './notify.vue'; -import XHeader from './ui.header.vue'; -import XNav from './ui.nav.vue'; - -export default Vue.extend({ - components: { - XHeader, - XNav - }, - - props: ['title'], - - data() { - return { - hasGameInvitation: false, - isDrawerOpening: false, - connection: null - }; - }, - - computed: { - hasUnreadNotification(): boolean { - return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadNotification; - }, - - hasUnreadMessagingMessage(): boolean { - return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadMessagingMessage; - }, - - indicate(): boolean { - return this.hasUnreadNotification || this.hasUnreadMessagingMessage || this.hasGameInvitation; - } - }, - - watch: { - '$store.state.uiHeaderHeight'() { - this.$el.style.paddingTop = this.$store.state.uiHeaderHeight + 'px'; - } - }, - - mounted() { - this.$el.style.paddingTop = this.$store.state.uiHeaderHeight + 'px'; - - if (this.$store.getters.isSignedIn) { - this.connection = this.$root.stream.useSharedConnection('main'); - - this.connection.on('notification', this.onNotification); - this.connection.on('reversiInvited', this.onReversiInvited); - this.connection.on('reversiNoInvites', this.onReversiNoInvites); - } - }, - - beforeDestroy() { - if (this.$store.getters.isSignedIn) { - this.connection.dispose(); - } - }, - - methods: { - onNotification(notification) { - // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない - this.$root.stream.send('readNotification', { - id: notification.id - }); - - this.$root.new(MkNotify, { - notification - }); - }, - - onReversiInvited() { - this.hasGameInvitation = true; - }, - - onReversiNoInvites() { - this.hasGameInvitation = false; - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-ui - &:not(.deck) - padding-top 48px - - > .button - position fixed - z-index 1000 - bottom 28px - padding 0 - width 64px - height 64px - border-radius 100% - box-shadow 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12) - - > * - font-size 24px - - &.nav - left 28px - background var(--secondary) - color var(--text) - - > i - position absolute - top 0 - left 0 - color var(--notificationIndicator) - font-size 16px - animation blink 1s infinite - - &.post - right 28px - background var(--primary) - color var(--primaryForeground) - -</style> diff --git a/src/client/app/mobile/views/components/user-list-timeline.vue b/src/client/app/mobile/views/components/user-list-timeline.vue deleted file mode 100644 index d9aa1dad8a..0000000000 --- a/src/client/app/mobile/views/components/user-list-timeline.vue +++ /dev/null @@ -1,76 +0,0 @@ -<template> -<mk-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: ['list'], - - data() { - return { - connection: null, - date: null, - pagination: { - endpoint: 'notes/user-list-timeline', - limit: 10, - params: init => ({ - listId: this.list.id, - untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes - }) - } - }; - }, - - watch: { - $route: 'init' - }, - - mounted() { - this.init(); - - this.$root.$on('warp', this.warp); - this.$once('hook:beforeDestroy', () => { - this.$root.$off('warp', this.warp); - }); - }, - - beforeDestroy() { - this.connection.dispose(); - }, - - methods: { - init() { - if (this.connection) this.connection.dispose(); - this.connection = this.$root.stream.connectToChannel('userList', { - listId: this.list.id - }); - this.connection.on('note', this.onNote); - this.connection.on('userAdded', this.onUserAdded); - this.connection.on('userRemoved', this.onUserRemoved); - }, - - onNote(note) { - // Prepend a note - (this.$refs.timeline as any).prepend(note); - }, - - onUserAdded() { - (this.$refs.timeline as any).reload(); - }, - - onUserRemoved() { - (this.$refs.timeline as any).reload(); - }, - - warp(date) { - this.date = date; - (this.$refs.timeline as any).reload(); - } - } -}); -</script> diff --git a/src/client/app/mobile/views/components/user-timeline.vue b/src/client/app/mobile/views/components/user-timeline.vue deleted file mode 100644 index 3b6baa76be..0000000000 --- a/src/client/app/mobile/views/components/user-timeline.vue +++ /dev/null @@ -1,43 +0,0 @@ -<template> -<mk-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('mobile/views/components/user-timeline.vue'), - - props: ['user', 'withMedia'], - - data() { - return { - date: null, - pagination: { - endpoint: 'users/notes', - limit: 10, - params: init => ({ - userId: this.user.id, - withFiles: this.withMedia, - untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), - }) - } - }; - }, - - created() { - this.$root.$on('warp', this.warp); - this.$once('hook:beforeDestroy', () => { - this.$root.$off('warp', this.warp); - }); - }, - - methods: { - warp(date) { - this.date = date; - (this.$refs.timeline as any).reload(); - } - } -}); -</script> diff --git a/src/client/app/mobile/views/directives/index.ts b/src/client/app/mobile/views/directives/index.ts deleted file mode 100644 index 324e07596d..0000000000 --- a/src/client/app/mobile/views/directives/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import Vue from 'vue'; - -import userPreview from './user-preview'; - -Vue.directive('userPreview', userPreview); -Vue.directive('user-preview', userPreview); diff --git a/src/client/app/mobile/views/directives/user-preview.ts b/src/client/app/mobile/views/directives/user-preview.ts deleted file mode 100644 index 1a54abc20d..0000000000 --- a/src/client/app/mobile/views/directives/user-preview.ts +++ /dev/null @@ -1,2 +0,0 @@ -// nope -export default {}; diff --git a/src/client/app/mobile/views/pages/drive.vue b/src/client/app/mobile/views/pages/drive.vue deleted file mode 100644 index 05163c6ed9..0000000000 --- a/src/client/app/mobile/views/pages/drive.vue +++ /dev/null @@ -1,147 +0,0 @@ -<template> -<mk-ui> - <template #header> - <template v-if="folder"><span style="margin-right:4px;"><fa :icon="['far', 'folder-open']"/></span>{{ folder.name }}</template> - <template v-if="file"><mk-file-type-icon data-icon :type="file.type" style="margin-right:4px;"/>{{ file.name }}</template> - <template v-if="!folder && !file"><span style="margin-right:4px;"><fa icon="cloud"/></span>{{ $t('@.drive') }}</template> - </template> - <template #func v-if="folder || (!folder && !file)"><button @click="openContextMenu" ref="contextSource"><fa icon="ellipsis-h"/></button></template> - <x-drive - ref="browser" - :init-folder="initFolder" - :init-file="initFile" - :is-naked="true" - :top="$store.state.uiHeaderHeight" - @begin-fetch="Progress.start()" - @fetched-mid="Progress.set(0.5)" - @fetched="Progress.done()" - @move-root="onMoveRoot" - @open-folder="onOpenFolder" - @open-file="onOpenFile" - /> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import Progress from '../../../common/scripts/loading'; -import XMenu from '../../../common/views/components/menu.vue'; -import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; -import { faCloudUploadAlt } from '@fortawesome/free-solid-svg-icons'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/drive.vue'), - components: { - XDrive: () => import('../components/drive.vue').then(m => m.default), - }, - data() { - return { - Progress, - folder: null, - file: null, - initFolder: null, - initFile: null - }; - }, - created() { - this.initFolder = this.$route.params.folder; - this.initFile = this.$route.params.file; - - window.addEventListener('popstate', this.onPopState); - }, - mounted() { - document.title = `${this.$root.instanceName} Drive`; - }, - beforeDestroy() { - window.removeEventListener('popstate', this.onPopState); - }, - methods: { - onPopState() { - if (this.$route.params.folder) { - (this.$refs as any).browser.cd(this.$route.params.folder, true); - } else if (this.$route.params.file) { - (this.$refs as any).browser.cf(this.$route.params.file, true); - } else { - (this.$refs as any).browser.goRoot(true); - } - }, - onMoveRoot(silent) { - const title = `${this.$root.instanceName} Drive`; - - if (!silent) { - // Rewrite URL - history.pushState(null, title, '/i/drive'); - } - - document.title = title; - - this.file = null; - this.folder = null; - }, - onOpenFolder(folder, silent) { - const title = `${folder.name} | ${this.$root.instanceName} Drive`; - - if (!silent) { - // Rewrite URL - history.pushState(null, title, `/i/drive/folder/${folder.id}`); - } - - document.title = title; - - this.file = null; - this.folder = folder; - }, - onOpenFile(file, silent) { - const title = `${file.name} | ${this.$root.instanceName} Drive`; - - if (!silent) { - // Rewrite URL - history.pushState(null, title, `/i/drive/file/${file.id}`); - } - - document.title = title; - - this.file = file; - this.folder = null; - }, - openContextMenu() { - this.$root.new(XMenu, { - items: [{ - type: 'item', - text: this.$t('contextmenu.upload'), - icon: 'upload', - action: this.$refs.browser.selectLocalFile - }, { - type: 'item', - text: this.$t('contextmenu.url-upload'), - icon: faCloudUploadAlt, - action: this.$refs.browser.urlUpload - }, { - type: 'item', - text: this.$t('contextmenu.create-folder'), - icon: ['far', 'folder'], - action: this.$refs.browser.createFolder - }, ...(this.folder ? [{ - type: 'item', - text: this.$t('contextmenu.rename-folder'), - icon: 'i-cursor', - action: this.$refs.browser.renameFolder - }, { - type: 'item', - text: this.$t('contextmenu.move-folder'), - icon: ['far', 'folder-open'], - action: this.$refs.browser.moveFolder - }, { - type: 'item', - text: this.$t('contextmenu.delete-folder'), - icon: faTrashAlt, - action: this.$refs.browser.deleteFolder - }] : [])], - source: this.$refs.contextSource, - }); - } - } -}); -</script> - diff --git a/src/client/app/mobile/views/pages/games/reversi.vue b/src/client/app/mobile/views/pages/games/reversi.vue deleted file mode 100644 index 69b7bdffb4..0000000000 --- a/src/client/app/mobile/views/pages/games/reversi.vue +++ /dev/null @@ -1,31 +0,0 @@ -<template> -<mk-ui> - <template #header><span style="margin-right:4px;"><fa icon="gamepad"/></span>{{ $t('reversi') }}</template> - <x-reversi :game-id="$route.params.game" @nav="nav" :self-nav="false"/> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/games/reversi.vue'), - components: { - XReversi: () => import('../../../../common/views/components/games/reversi/reversi.vue').then(m => m.default) - }, - mounted() { - document.title = `${this.$root.instanceName} | ${this.$t('reversi')}`; - }, - methods: { - nav(game, actualNav) { - if (actualNav) { - this.$router.push(`/games/reversi/${game.id}`); - } else { - // TODO: https://github.com/vuejs/vue-router/issues/703 - this.$router.push(`/games/reversi/${game.id}`); - } - } - } -}); -</script> diff --git a/src/client/app/mobile/views/pages/home.timeline.vue b/src/client/app/mobile/views/pages/home.timeline.vue deleted file mode 100644 index f115458092..0000000000 --- a/src/client/app/mobile/views/pages/home.timeline.vue +++ /dev/null @@ -1,143 +0,0 @@ -<template> -<div> - <ui-container v-if="src == 'home' && alone" :show-header="false" style="margin-bottom:8px;"> - <div class="zrzngnxs"> - <p>{{ $t('@.empty-timeline-info.follow-users-to-make-your-timeline') }}</p> - <router-link to="/explore">{{ $t('@.empty-timeline-info.explore') }}</router-link> - </div> - </ui-container> - - <mk-notes ref="timeline" :pagination="pagination" @loaded="() => $emit('loaded')"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/home.timeline.vue'), - - props: { - src: { - type: String, - required: true - }, - tagTl: { - required: false - } - }, - - data() { - return { - streamManager: null, - connection: null, - unreadCount: 0, - date: null, - baseQuery: { - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes - }, - query: {}, - endpoint: null, - pagination: null - }; - }, - - computed: { - alone(): boolean { - return this.$store.state.i.followingCount == 0; - } - }, - - created() { - this.$root.$on('warp', this.warp); - this.$once('hook:beforeDestroy', () => { - this.$root.$off('warp', this.warp); - this.connection.dispose(); - }); - - const prepend = note => { - (this.$refs.timeline as any).prepend(note); - }; - - if (this.src == 'tag') { - this.endpoint = 'notes/search-by-tag'; - this.query = { - query: this.tagTl.query - }; - this.connection = this.$root.stream.connectToChannel('hashtag', { q: this.tagTl.query }); - this.connection.on('note', prepend); - } else if (this.src == 'home') { - this.endpoint = 'notes/timeline'; - const onChangeFollowing = () => { - this.fetch(); - }; - this.connection = this.$root.stream.useSharedConnection('homeTimeline'); - this.connection.on('note', prepend); - this.connection.on('follow', onChangeFollowing); - this.connection.on('unfollow', onChangeFollowing); - } else if (this.src == 'local') { - this.endpoint = 'notes/local-timeline'; - this.connection = this.$root.stream.useSharedConnection('localTimeline'); - this.connection.on('note', prepend); - } else if (this.src == 'hybrid') { - this.endpoint = 'notes/hybrid-timeline'; - this.connection = this.$root.stream.useSharedConnection('hybridTimeline'); - this.connection.on('note', prepend); - } else if (this.src == 'global') { - this.endpoint = 'notes/global-timeline'; - this.connection = this.$root.stream.useSharedConnection('globalTimeline'); - this.connection.on('note', prepend); - } else if (this.src == 'mentions') { - this.endpoint = 'notes/mentions'; - this.connection = this.$root.stream.useSharedConnection('main'); - this.connection.on('mention', prepend); - } else if (this.src == 'messages') { - this.endpoint = 'notes/mentions'; - this.query = { - visibility: 'specified' - }; - const onNote = note => { - if (note.visibility == 'specified') { - prepend(note); - } - }; - this.connection = this.$root.stream.useSharedConnection('main'); - this.connection.on('mention', onNote); - } - - this.pagination = { - endpoint: this.endpoint, - limit: 10, - params: init => ({ - untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), - ...this.baseQuery, ...this.query - }) - }; - }, - - methods: { - focus() { - (this.$refs.timeline as any).focus(); - }, - - warp(date) { - this.date = date; - (this.$refs.timeline as any).reload(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.zrzngnxs - padding 16px - text-align center - font-size 14px - - > p - margin 0 0 8px 0 - -</style> diff --git a/src/client/app/mobile/views/pages/home.vue b/src/client/app/mobile/views/pages/home.vue deleted file mode 100644 index 0d110bf2ee..0000000000 --- a/src/client/app/mobile/views/pages/home.vue +++ /dev/null @@ -1,249 +0,0 @@ -<template> -<mk-ui> - <template #header> - <span @click="showNav = true"> - <span :class="$style.title"> - <span v-if="src == 'home'"><fa icon="home"/>{{ $t('home') }}</span> - <span v-if="src == 'local'"><fa :icon="['far', 'comments']"/>{{ $t('local') }}</span> - <span v-if="src == 'hybrid'"><fa icon="share-alt"/>{{ $t('hybrid') }}</span> - <span v-if="src == 'global'"><fa icon="globe"/>{{ $t('global') }}</span> - <span v-if="src == 'mentions'"><fa icon="at"/>{{ $t('mentions') }}</span> - <span v-if="src == 'messages'"><fa :icon="['far', 'envelope']"/>{{ $t('messages') }}</span> - <span v-if="src == 'list'"><fa icon="list"/>{{ list.name }}</span> - <span v-if="src == 'tag'"><fa icon="hashtag"/>{{ tagTl.title }}</span> - </span> - <span style="margin-left:8px"> - <template v-if="!showNav"><fa icon="angle-down"/></template> - <template v-else><fa icon="angle-up"/></template> - </span> - <i :class="$style.badge" v-if="$store.state.i.hasUnreadMentions || $store.state.i.hasUnreadSpecifiedNotes"><fa icon="circle"/></i> - </span> - </template> - - <template #func> - <button @click="fn"><fa icon="pencil-alt"/></button> - </template> - - <main> - <div class="nav" v-if="showNav"> - <div class="bg" @click="showNav = false"></div> - <div class="pointer"></div> - <div class="body"> - <div> - <span :data-active="src == 'home'" @click="src = 'home'"><fa icon="home"/> {{ $t('home') }}</span> - <span :data-active="src == 'local'" @click="src = 'local'" v-if="enableLocalTimeline"><fa :icon="['far', 'comments']"/> {{ $t('local') }}</span> - <span :data-active="src == 'hybrid'" @click="src = 'hybrid'" v-if="enableLocalTimeline"><fa icon="share-alt"/> {{ $t('hybrid') }}</span> - <span :data-active="src == 'global'" @click="src = 'global'" v-if="enableGlobalTimeline"><fa icon="globe"/> {{ $t('global') }}</span> - <div class="hr"></div> - <span :data-active="src == 'mentions'" @click="src = 'mentions'"><fa icon="at"/> {{ $t('mentions') }}<i class="badge" v-if="$store.state.i.hasUnreadMentions"><fa icon="circle"/></i></span> - <span :data-active="src == 'messages'" @click="src = 'messages'"><fa :icon="['far', 'envelope']"/> {{ $t('messages') }}<i class="badge" v-if="$store.state.i.hasUnreadSpecifiedNotes"><fa icon="circle"/></i></span> - <template v-if="lists"> - <div class="hr" v-if="lists.length > 0"></div> - <span v-for="l in lists" :data-active="src == 'list' && list == l" @click="src = 'list'; list = l" :key="l.id"><fa icon="list"/> {{ l.name }}</span> - </template> - <div class="hr" v-if="$store.state.settings.tagTimelines && $store.state.settings.tagTimelines.length > 0"></div> - <span v-for="tl in $store.state.settings.tagTimelines" :data-active="src == 'tag' && tagTl == tl" @click="src = 'tag'; tagTl = tl" :key="tl.id"><fa icon="hashtag"/> {{ tl.title }}</span> - </div> - </div> - </div> - - <div class="tl"> - <x-tl v-if="src == 'home'" ref="tl" key="home" src="home"/> - <x-tl v-if="src == 'local'" ref="tl" key="local" src="local"/> - <x-tl v-if="src == 'hybrid'" ref="tl" key="hybrid" src="hybrid"/> - <x-tl v-if="src == 'global'" ref="tl" key="global" src="global"/> - <x-tl v-if="src == 'mentions'" ref="tl" key="mentions" src="mentions"/> - <x-tl v-if="src == 'messages'" ref="tl" key="messages" src="messages"/> - <x-tl v-if="src == 'tag'" ref="tl" key="tag" src="tag" :tag-tl="tagTl"/> - <mk-user-list-timeline v-if="src == 'list'" ref="tl" :key="list.id" :list="list"/> - </div> - </main> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import Progress from '../../../common/scripts/loading'; -import XTl from './home.timeline.vue'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/home.vue'), - - components: { - XTl - }, - - data() { - return { - src: 'home', - list: null, - lists: null, - tagTl: null, - showNav: false, - enableLocalTimeline: false, - enableGlobalTimeline: false, - }; - }, - - watch: { - src() { - this.showNav = false; - this.saveSrc(); - }, - - list(x) { - this.showNav = false; - this.saveSrc(); - if (x != null) this.tagTl = null; - }, - - tagTl(x) { - this.showNav = false; - this.saveSrc(); - if (x != null) this.list = null; - }, - - showNav(v) { - if (v && this.lists === null) { - this.$root.api('users/lists/list').then(lists => { - this.lists = lists; - }); - } - } - }, - - created() { - this.$root.getMeta().then((meta: Record<string, any>) => { - if (!( - this.enableGlobalTimeline = !meta.disableGlobalTimeline || this.$store.state.i.isModerator || this.$store.state.i.isAdmin - ) && this.src === 'global') this.src = 'local'; - if (!( - this.enableLocalTimeline = !meta.disableLocalTimeline || this.$store.state.i.isModerator || this.$store.state.i.isAdmin - ) && ['local', 'hybrid'].includes(this.src)) this.src = 'home'; - }); - - if (this.$store.state.device.tl) { - this.src = this.$store.state.device.tl.src; - if (this.src == 'list') { - this.list = this.$store.state.device.tl.arg; - } else if (this.src == 'tag') { - this.tagTl = this.$store.state.device.tl.arg; - } - } - }, - - mounted() { - document.title = this.$root.instanceName; - - Progress.start(); - - (this.$refs.tl as any).$once('loaded', () => { - Progress.done(); - }); - }, - - methods: { - fn() { - this.$post(); - }, - - saveSrc() { - this.$store.commit('device/setTl', { - src: this.src, - arg: this.src == 'list' ? this.list : this.tagTl - }); - }, - - warp() { - - } - } -}); -</script> - -<style lang="stylus" scoped> -main - > .nav - > .pointer - position fixed - z-index 10002 - top 56px - left 0 - right 0 - - $size = 16px - - &:after - content "" - display block - position absolute - top -($size * 2) - left s('calc(50% - %s)', $size) - border-top solid $size transparent - border-left solid $size transparent - border-right solid $size transparent - border-bottom solid $size var(--popupBg) - - > .bg - position fixed - z-index 10000 - top 0 - left 0 - width 100% - height 100% - background rgba(#000, 0.5) - - > .body - position fixed - z-index 10001 - top 56px - left 0 - right 0 - width 300px - max-height calc(100% - 70px) - margin 0 auto - overflow auto - -webkit-overflow-scrolling touch - background var(--popupBg) - border-radius 8px - box-shadow 0 0 16px rgba(#000, 0.1) - - > div - padding 8px 0 - - > .hr - margin 8px 0 - border-top solid 1px var(--faceDivider) - - > *:not(.hr) - display block - padding 8px 16px - color var(--text) - - &[data-active] - color var(--primaryForeground) - background var(--primary) - - &:not([data-active]):hover - background var(--mobileHomeTlItemHover) - - > .badge - margin-left 6px - font-size 10px - color var(--notificationIndicator) - -</style> - -<style lang="stylus" module> -.title - [data-icon] - margin-right 4px - -.badge - margin-left 6px - font-size 10px - color var(--notificationIndicator) - vertical-align middle - -</style> diff --git a/src/client/app/mobile/views/pages/messaging-room.vue b/src/client/app/mobile/views/pages/messaging-room.vue deleted file mode 100644 index 7872847127..0000000000 --- a/src/client/app/mobile/views/pages/messaging-room.vue +++ /dev/null @@ -1,71 +0,0 @@ -<template> -<mk-ui> - <template #header> - <template v-if="user"><span style="margin-right:4px;"><fa :icon="['far', 'comments']"/></span><mk-user-name :user="user"/></template> - <template v-else-if="group"><span style="margin-right:4px;"><fa :icon="['far', 'comments']"/></span>{{ group.name }}</template> - <template v-else><mk-ellipsis/></template> - </template> - <x-messaging-room v-if="!fetching" :user="user" :group="group" :is-naked="true"/> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import parseAcct from '../../../../../misc/acct/parse'; - -export default Vue.extend({ - i18n: i18n(), - components: { - XMessagingRoom: () => import('../../../common/views/components/messaging-room.vue').then(m => m.default) - }, - data() { - return { - fetching: true, - user: null, - group: null, - unwatchDarkmode: null - }; - }, - watch: { - $route: 'fetch' - }, - created() { - const applyBg = v => - document.documentElement.style.setProperty('background', v ? '#191b22' : '#fff', 'important'); - - applyBg(this.$store.state.device.darkmode); - - this.unwatchDarkmode = this.$store.watch(s => { - return s.device.darkmode; - }, applyBg); - - this.fetch(); - }, - beforeDestroy() { - document.documentElement.style.removeProperty('background'); - document.documentElement.style.removeProperty('background-color'); // for safari's bug - this.unwatchDarkmode(); - }, - methods: { - fetch() { - this.fetching = true; - if (this.$route.params.user) { - this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => { - this.user = user; - this.fetching = false; - - document.title = `${this.$t('@.messaging')}: ${Vue.filter('userName')(this.user)} | ${this.$root.instanceName}`; - }); - } else { - this.$root.api('users/groups/show', { groupId: this.$route.params.group }).then(group => { - this.group = group; - this.fetching = false; - - document.title = this.$t('@.messaging') + ': ' + this.group.name; - }); - } - } - } -}); -</script> diff --git a/src/client/app/mobile/views/pages/messaging.vue b/src/client/app/mobile/views/pages/messaging.vue deleted file mode 100644 index ff66ae06e6..0000000000 --- a/src/client/app/mobile/views/pages/messaging.vue +++ /dev/null @@ -1,30 +0,0 @@ -<template> -<mk-ui> - <template #header><span style="margin-right:4px;"><fa :icon="['far', 'comments']"/></span>{{ $t('@.messaging') }}</template> - <x-messaging @navigate="navigate" @navigateGroup="navigateGroup" :header-top="48"/> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import getAcct from '../../../../../misc/acct/render'; - -export default Vue.extend({ - i18n: i18n(), - components: { - XMessaging: () => import('../../../common/views/components/messaging.vue').then(m => m.default) - }, - mounted() { - document.title = `${this.$root.instanceName} ${this.$t('@.messaging')}`; - }, - methods: { - navigate(user) { - (this as any).$router.push(`/i/messaging/${getAcct(user)}`); - }, - navigateGroup(group) { - (this as any).$router.push(`/i/messaging/group/${group.id}`); - } - } -}); -</script> diff --git a/src/client/app/mobile/views/pages/note.vue b/src/client/app/mobile/views/pages/note.vue deleted file mode 100644 index 090851fc4e..0000000000 --- a/src/client/app/mobile/views/pages/note.vue +++ /dev/null @@ -1,67 +0,0 @@ -<template> -<mk-ui> - <template #header><span style="margin-right:4px;"><fa :icon="['far', 'sticky-note']"/></span>{{ $t('title') }}</template> - <main v-if="!fetching"> - <div> - <mk-note-detail :note="note" :key="note.id"/> - </div> - <footer> - <router-link v-if="note.prev" :to="note.prev"><fa icon="angle-left"/> {{ $t('prev') }}</router-link> - <router-link v-if="note.next" :to="note.next">{{ $t('next') }} <fa icon="angle-right"/></router-link> - </footer> - </main> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import Progress from '../../../common/scripts/loading'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/note.vue'), - data() { - return { - fetching: true, - note: null - }; - }, - watch: { - $route: 'fetch' - }, - created() { - this.fetch(); - }, - mounted() { - document.title = this.$root.instanceName; - }, - methods: { - fetch() { - Progress.start(); - this.fetching = true; - - this.$root.api('notes/show', { - noteId: this.$route.params.note - }).then(note => { - this.note = note; - this.fetching = false; - - Progress.done(); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -main - text-align center - - > footer - margin-top 16px - - > a - display inline-block - margin 0 16px - -</style> diff --git a/src/client/app/mobile/views/pages/notifications.vue b/src/client/app/mobile/views/pages/notifications.vue deleted file mode 100644 index 24f8f79ccc..0000000000 --- a/src/client/app/mobile/views/pages/notifications.vue +++ /dev/null @@ -1,71 +0,0 @@ -<template> -<mk-ui> - <template #header><fa :icon="faBell"/> {{ $t('notifications') }}</template> - <template #func> - <button @click="filter()"><fa icon="cog"/></button> - </template> - - <main> - <mk-notifications @before-init="beforeInit()" @inited="inited()" :type="type === 'all' ? null : type" :wide="true" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"/> - </main> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faBell } from '@fortawesome/free-regular-svg-icons'; -import i18n from '../../../i18n'; -import Progress from '../../../common/scripts/loading'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/notifications.vue'), - data() { - return { - type: 'all', - faBell, - }; - }, - mounted() { - document.title = this.$root.instanceName; - }, - methods: { - beforeInit() { - Progress.start(); - }, - inited() { - Progress.done(); - }, - filter() { - this.$root.dialog({ - title: this.$t('@.notification-type'), - type: null, - select: { - items: ['all', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest'].map(x => ({ - value: x, text: this.$t('@.notification-types.' + x) - })) - default: this.type, - }, - showCancelButton: true - }).then(({ canceled, result: type }) => { - if (canceled) return; - this.type = type; - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -main > * - overflow hidden - background var(--face) - - &.round - border-radius 8px - - &.shadow - box-shadow 0 4px 16px rgba(#000, 0.1) - - @media (min-width 500px) - box-shadow 0 8px 32px rgba(#000, 0.1) -</style> diff --git a/src/client/app/mobile/views/pages/search.vue b/src/client/app/mobile/views/pages/search.vue deleted file mode 100644 index dca1ffd40a..0000000000 --- a/src/client/app/mobile/views/pages/search.vue +++ /dev/null @@ -1,47 +0,0 @@ -<template> -<mk-ui> - <template #header><fa icon="search"/> {{ q }}</template> - - <main> - <mk-notes ref="timeline" :pagination="pagination" @inited="inited"/> - </main> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import Progress from '../../../common/scripts/loading'; -import { genSearchQuery } from '../../../common/scripts/gen-search-query'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/search.vue'), - data() { - return { - pagination: { - endpoint: 'notes/search', - limit: 20, - params: () => genSearchQuery(this, this.q) - } - }; - }, - computed: { - q(): string { - return this.$route.query.q; - } - }, - watch: { - $route() { - this.$refs.timeline.reload(); - } - }, - mounted() { - document.title = `${this.$t('search')}: ${this.q} | ${this.$root.instanceName}`; - }, - methods: { - inited() { - Progress.done(); - }, - } -}); -</script> diff --git a/src/client/app/mobile/views/pages/selectdrive.vue b/src/client/app/mobile/views/pages/selectdrive.vue deleted file mode 100644 index 095c19cf2c..0000000000 --- a/src/client/app/mobile/views/pages/selectdrive.vue +++ /dev/null @@ -1,101 +0,0 @@ -<template> -<div class="mk-selectdrive"> - <header> - <h1>{{ $t('select-file') }}<span class="count" v-if="files.length > 0">({{ files.length }})</span></h1> - <button class="upload" @click="upload"><fa icon="upload"/></button> - <button v-if="multiple" class="ok" @click="ok"><fa icon="check"/></button> - </header> - <x-drive ref="browser" select-file :multiple="multiple" is-naked :top="$store.state.uiHeaderHeight"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/selectdrive.vue'), - components: { - XDrive: () => import('../components/drive.vue').then(m => m.default), - }, - data() { - return { - files: [] - }; - }, - computed: { - multiple(): boolean { - const q = (new URL(location.toString())).searchParams; - return q.get('multiple') == 'true'; - } - }, - mounted() { - document.title = this.$t('title'); - }, - methods: { - onSelected(file) { - this.files = [file]; - this.ok(); - }, - onChangeSelection(files) { - this.files = files; - }, - upload() { - (this.$refs.browser as any).selectLocalFile(); - }, - close() { - window.close(); - }, - ok() { - window.opener.cb(this.multiple ? this.files : this.files[0]); - this.close(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-selectdrive - width 100% - height 100% - background #fff - - > header - position fixed - top 0 - left 0 - width 100% - z-index 1000 - background #fff - box-shadow 0 1px rgba(#000, 0.1) - - > h1 - margin 0 - padding 0 - text-align center - line-height 42px - font-size 1em - font-weight normal - - > .count - margin-left 4px - opacity 0.5 - - > .upload - position absolute - top 0 - left 0 - line-height 42px - width 42px - - > .ok - position absolute - top 0 - right 0 - line-height 42px - width 42px - - > .mk-drive - top 42px - -</style> diff --git a/src/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue deleted file mode 100644 index c24a56be7b..0000000000 --- a/src/client/app/mobile/views/pages/settings.vue +++ /dev/null @@ -1,86 +0,0 @@ -<template> -<mk-ui> - <template #header><span style="margin-right:4px;"><fa icon="cog"/></span>{{ $t('@.settings') }}</template> - <main> - <div class="signed-in-as" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> - <mfm :text="$t('signed-in-as').replace('{}', name)" :plain="true" :custom-emojis="$store.state.i.emojis"/> - </div> - - <x-settings/> - - <div class="signout" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }" @click="signout">{{ $t('@.signout') }}</div> - - <footer> - <small>ver {{ version }} ({{ codename }})</small> - </footer> - </main> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import XSettings from '../../../common/views/components/settings/settings.vue'; -import { version, codename } from '../../../config'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/settings.vue'), - components: { - XSettings, - }, - data() { - return { - version, - codename, - }; - }, - computed: { - name(): string { - return Vue.filter('userName')(this.$store.state.i); - }, - }, - methods: { - signout() { - this.$root.signout(); - }, - } -}); -</script> - -<style lang="stylus" scoped> -main - - > .signed-in-as - margin 16px - padding 16px - text-align center - color var(--mobileSignedInAsFg) - background var(--mobileSignedInAsBg) - font-weight bold - - &.round - border-radius 6px - - &.shadow - box-shadow 0 3px 1px -2px rgba(#000, 0.2), 0 2px 2px 0 rgba(#000, 0.14), 0 1px 5px 0 rgba(#000, 0.12) - - > .signout - margin 16px - padding 16px - text-align center - color var(--mobileSignedInAsFg) - background var(--mobileSignedInAsBg) - - &.round - border-radius 6px - - &.shadow - box-shadow 0 3px 1px -2px rgba(#000, 0.2), 0 2px 2px 0 rgba(#000, 0.14), 0 1px 5px 0 rgba(#000, 0.12) - - > footer - margin 16px - text-align center - color var(--text) - opacity 0.7 - -</style> diff --git a/src/client/app/mobile/views/pages/signup.vue b/src/client/app/mobile/views/pages/signup.vue deleted file mode 100644 index 81d2741ae5..0000000000 --- a/src/client/app/mobile/views/pages/signup.vue +++ /dev/null @@ -1,29 +0,0 @@ -<template> -<div class="signup"> - <h1>{{ $t('lets-start') }}</h1> - <mk-signup/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -export default Vue.extend({ - i18n: i18n('mobile/views/pages/signup.vue') -}); -</script> - -<style lang="stylus" scoped> -.signup - padding 32px - margin 0 auto - max-width 500px - - h1 - margin 0 - padding 8px 0 0 0 - font-size 1.5em - font-weight bold - color var(--text) - -</style> diff --git a/src/client/app/mobile/views/pages/tag.vue b/src/client/app/mobile/views/pages/tag.vue deleted file mode 100644 index 19482ec382..0000000000 --- a/src/client/app/mobile/views/pages/tag.vue +++ /dev/null @@ -1,40 +0,0 @@ -<template> -<mk-ui> - <template #header><span style="margin-right:4px;"><fa icon="hashtag"/></span>{{ $route.params.tag }}</template> - - <main> - <mk-notes ref="timeline" :pagination="pagination" @inited="inited"/> - </main> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import Progress from '../../../common/scripts/loading'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/tag.vue'), - data() { - return { - pagination: { - endpoint: 'notes/search-by-tag', - limit: 20, - params: { - tag: this.$route.params.tag - } - } - }; - }, - watch: { - $route() { - this.$refs.timeline.reload(); - } - }, - methods: { - inited() { - Progress.done(); - }, - } -}); -</script> diff --git a/src/client/app/mobile/views/pages/ui.vue b/src/client/app/mobile/views/pages/ui.vue deleted file mode 100644 index 397ba5df07..0000000000 --- a/src/client/app/mobile/views/pages/ui.vue +++ /dev/null @@ -1,38 +0,0 @@ -<template> -<mk-ui> - <template #header><span style="margin-right:4px;" v-if="icon"><fa :icon="icon"/></span>{{ title }}</template> - - <main> - <component :is="component" @init="init" v-bind="$attrs"/> - </main> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: { - component: { - required: true - } - }, - - data() { - return { - title: null, - icon: null, - }; - }, - - mounted() { - }, - - methods: { - init(v) { - this.title = v.title; - this.icon = v.icon; - } - } -}); -</script> diff --git a/src/client/app/mobile/views/pages/user/home.notes.vue b/src/client/app/mobile/views/pages/user/home.notes.vue deleted file mode 100644 index 9abe5b893c..0000000000 --- a/src/client/app/mobile/views/pages/user/home.notes.vue +++ /dev/null @@ -1,59 +0,0 @@ -<template> -<div class="root notes"> - <p class="fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p> - <div v-if="!fetching && notes.length > 0"> - <mk-note-card v-for="note in notes" :key="note.id" :note="note"/> - </div> - <p class="empty" v-if="!fetching && notes.length == 0">{{ $t('@.no-notes') }}</p> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; -export default Vue.extend({ - i18n: i18n('mobile/views/pages/user/home.notes.vue'), - props: ['user'], - data() { - return { - fetching: true, - notes: [] - }; - }, - mounted() { - this.$root.api('users/notes', { - userId: this.user.id, - }).then(notes => { - this.notes = notes; - this.fetching = false; - }); - } -}); -</script> - -<style lang="stylus" scoped> -.root.notes - - > div - overflow-x scroll - -webkit-overflow-scrolling touch - white-space nowrap - padding 8px - - > * - vertical-align top - - &:not(:last-child) - margin-right 8px - - > .fetching - > .empty - margin 0 - padding 16px - text-align center - color var(--text) - - > i - margin-right 4px - -</style> diff --git a/src/client/app/mobile/views/pages/user/home.vue b/src/client/app/mobile/views/pages/user/home.vue deleted file mode 100644 index 316b2a12fe..0000000000 --- a/src/client/app/mobile/views/pages/user/home.vue +++ /dev/null @@ -1,70 +0,0 @@ -<template> -<div class="wojmldye"> - <x-page class="page" v-if="user.pinnedPage" :page="user.pinnedPage" :key="user.pinnedPage.id" :show-title="!user.pinnedPage.hideTitleWhenPinned"/> - <mk-note-detail class="note" v-for="n in user.pinnedNotes" :key="n.id" :note="n" :compact="true"/> - <ui-container :body-togglable="true"> - <template #header><fa :icon="['far', 'comments']"/>{{ $t('recent-notes') }}</template> - <div> - <x-notes :user="user"/> - </div> - </ui-container> - <ui-container :body-togglable="true"> - <template #header><fa icon="image"/>{{ $t('images') }}</template> - <div> - <x-photos :user="user"/> - </div> - </ui-container> - <ui-container :body-togglable="true"> - <template #header><fa icon="chart-bar"/>{{ $t('activity') }}</template> - <div style="padding:8px;"> - <x-activity :user="user"/> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; -import XNotes from './home.notes.vue'; -import XPhotos from './home.photos.vue'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/user/home.vue'), - components: { - XNotes, - XPhotos, - XPage: () => import('../../../../common/views/components/page/page.vue').then(m => m.default), - XActivity: () => import('../../../../common/views/components/activity.vue').then(m => m.default) - }, - props: ['user'], - data() { - return { - makeFrequentlyRepliedUsersPromise: () => this.$root.api('users/get_frequently_replied_users', { - userId: this.user.id - }).then(res => res.map(x => x.user)), - makeFollowersYouKnowPromise: () => this.$root.api('users/followers', { - userId: this.user.id, - iknow: true, - limit: 30 - }).then(res => res.users), - }; - } -}); -</script> - -<style lang="stylus" scoped> -.wojmldye - > .page - margin 0 0 8px 0 - - @media (min-width 500px) - margin 0 0 16px 0 - - > .note - margin 0 0 8px 0 - - @media (min-width 500px) - margin 0 0 16px 0 - -</style> diff --git a/src/client/app/mobile/views/pages/user/index.vue b/src/client/app/mobile/views/pages/user/index.vue deleted file mode 100644 index b8a79a6b34..0000000000 --- a/src/client/app/mobile/views/pages/user/index.vue +++ /dev/null @@ -1,349 +0,0 @@ -<template> -<mk-ui> - <template #header v-if="!fetching"> - <img :src="avator" alt=""><mk-user-name :user="user" :key="user.id"/> - </template> - <div class="wwtwuxyh" v-if="!fetching"> - <div class="is-suspended" v-if="user.isSuspended"><p><fa icon="exclamation-triangle"/> {{ $t('@.user-suspended') }}</p></div> - <div class="is-remote" v-if="user.host != null"><p><fa icon="exclamation-triangle"/> {{ $t('@.is-remote-user') }}<a :href="user.url" rel="nofollow noopener" target="_blank">{{ $t('@.view-on-remote') }}</a></p></div> - <header> - <div class="banner" :style="style"></div> - <div class="body"> - <div class="top"> - <a class="avatar"> - <img :src="avator" alt="avatar"/> - </a> - <button class="menu" ref="menu" @click="menu"><fa icon="ellipsis-h"/></button> - <mk-follow-button v-if="$store.getters.isSignedIn && $store.state.i.id != user.id" :user="user"/> - </div> - <div class="title"> - <h1><mk-user-name :user="user" :key="user.id" :nowrap="false"/></h1> - <span class="username"><mk-acct :user="user" :detail="true" :key="user.id"/></span> - <span class="followed" v-if="user.isFollowed">{{ $t('follows-you') }}</span> - </div> - <div class="description"> - <mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :key="user.id"/> - <x-integrations :user="user" style="margin:20px 0;"/> - </div> - <div class="fields" v-if="user.fields" :key="user.id"> - <dl class="field" v-for="(field, i) in user.fields" :key="i"> - <dt class="name"> - <mfm :text="field.name" :plain="true" :custom-emojis="user.emojis"/> - </dt> - <dd class="value"> - <mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> - </dd> - </dl> - </div> - <div class="info"> - <p class="location" v-if="user.host === null && user.location"> - <fa icon="map-marker"/>{{ user.location }} - </p> - <p class="birthday" v-if="user.host === null && user.birthday"> - <fa icon="birthday-cake"/>{{ user.birthday.replace('-', '年').replace('-', '月') + '日' }} ({{ $t('years-old', { age }) }}) - </p> - </div> - <div class="status"> - <router-link :to="user | userPage()"> - <b>{{ user.notesCount | number }}</b> - <i>{{ $t('notes') }}</i> - </router-link> - <router-link :to="user | userPage('following')"> - <b>{{ user.followingCount | number }}</b> - <i>{{ $t('following') }}</i> - </router-link> - <router-link :to="user | userPage('followers')"> - <b>{{ user.followersCount | number }}</b> - <i>{{ $t('followers') }}</i> - </router-link> - </div> - </div> - </header> - <nav v-if="$route.name == 'user'" :class="{ shadow: $store.state.device.useShadow }"> - <div class="nav-container"> - <a :data-active="page == 'home'" @click="page = 'home'"><fa icon="home"/> {{ $t('overview') }}</a> - <a :data-active="page == 'notes'" @click="page = 'notes'"><fa :icon="['far', 'comment-alt']"/> {{ $t('timeline') }}</a> - <a :data-active="page == 'media'" @click="page = 'media'"><fa icon="image"/> {{ $t('media') }}</a> - </div> - </nav> - <main> - <template v-if="$route.name == 'user'"> - <x-home v-if="page == 'home'" :user="user" :key="user.id"/> - <mk-user-timeline v-if="page == 'notes'" :user="user" :key="`tl:${user.id}`"/> - <mk-user-timeline v-if="page == 'media'" :user="user" :with-media="true" :key="`media:${user.id}`"/> - </template> - <router-view :user="user"></router-view> - </main> - </div> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../../i18n'; -import * as age from 's-age'; -import parseAcct from '../../../../../../misc/acct/parse'; -import Progress from '../../../../common/scripts/loading'; -import XUserMenu from '../../../../common/views/components/user-menu.vue'; -import XHome from './home.vue'; -import { getStaticImageUrl } from '../../../../common/scripts/get-static-image-url'; -import XIntegrations from '../../../../common/views/components/integrations.vue'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/user.vue'), - components: { - XHome, - XIntegrations - }, - data() { - return { - fetching: true, - user: null, - page: this.$route.name == 'user' ? 'home' : null - }; - }, - computed: { - age(): number { - return age(this.user.birthday); - }, - avator(): string { - return this.$store.state.device.disableShowingAnimatedImages - ? getStaticImageUrl(this.user.avatarUrl) - : this.user.avatarUrl; - }, - style(): any { - if (this.user.bannerUrl == null) return {}; - return { - backgroundColor: this.user.bannerColor, - backgroundImage: `url(${ this.user.bannerUrl })` - }; - } - }, - watch: { - $route: 'fetch' - }, - created() { - this.fetch(); - }, - methods: { - fetch() { - Progress.start(); - - this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => { - this.user = user; - this.fetching = false; - - Progress.done(); - document.title = `${Vue.filter('userName')(this.user)} | ${this.$root.instanceName}`; - }); - }, - - menu() { - this.$root.new(XUserMenu, { - source: this.$refs.menu, - user: this.user - }); - }, - } -}); -</script> - -<style lang="stylus" scoped> -.wwtwuxyh - $bg = var(--face) - - > .is-suspended - > .is-remote - &.is-suspended - color #570808 - background #ffdbdb - - &.is-remote - color #573c08 - background #fff0db - - > p - margin 0 auto - padding 14px - max-width 600px - font-size 14px - - > a - font-weight bold - - @media (max-width 500px) - padding 12px - font-size 12px - - > header - background $bg - - > .banner - padding-bottom 33.3% - background-color rgba(0, 0, 0, 0.1) - background-size cover - background-position center - - > .body - padding 12px - margin 0 auto - max-width 600px - - > .top - display flex - - > .avatar - display block - width 25% - height 40px - - > img - display block - position absolute - left -2px - bottom -2px - width 100% - background $bg - border 3px solid $bg - border-radius 6px - - @media (min-width 500px) - left -4px - bottom -4px - border 4px solid $bg - border-radius 12px - - > .menu - margin 0 0 0 auto - padding 8px - margin-right 8px - font-size 18px - color var(--text) - - > .title - margin 8px 0 - - > h1 - margin 0 - line-height 22px - font-size 20px - color var(--mobileUserPageName) - - > .username - display inline-block - line-height 20px - font-size 16px - font-weight bold - color var(--mobileUserPageAcct) - - > .followed - margin-left 8px - padding 2px 4px - font-size 12px - color var(--mobileUserPageFollowedFg) - background var(--mobileUserPageFollowedBg) - border-radius 4px - - > .description - margin 8px 0 - color var(--mobileUserPageDescription) - - @media (max-width 450px) - font-size 15px - - > .fields - margin 8px 0 - - > .field - display flex - padding 0 - margin 0 - align-items center - - > .name - padding 4px - margin 4px - width 30% - overflow hidden - white-space nowrap - text-overflow ellipsis - font-weight bold - color var(--mobileUserPageStatusHighlight) - - > .value - padding 4px - margin 4px - width 70% - overflow hidden - white-space nowrap - text-overflow ellipsis - color var(--mobileUserPageStatusHighlight) - - > .info - margin 8px 0 - - @media (max-width 450px) - font-size 15px - - > p - display inline - margin 0 16px 0 0 - color var(--text) - - > i - margin-right 4px - - > .status - > a - color var(--text) - - &:not(:last-child) - margin-right 16px - - > b - margin-right 4px - font-size 16px - color var(--mobileUserPageStatusHighlight) - - > i - font-size 14px - - > button - color var(--text) - - > nav - position -webkit-sticky - position sticky - top 47px - background-color $bg - z-index 2 - - &.shadow - box-shadow 0 4px 4px var(--mobileUserPageHeaderShadow) - - > .nav-container - display flex - justify-content center - margin 0 auto - max-width 616px - - > a - display block - flex 1 1 - text-align center - line-height 48px - font-size 12px - text-decoration none - color var(--text) - border-bottom solid 2px transparent - - @media (min-width 400px) - line-height 52px - font-size 14px - - &[data-active] - font-weight bold - color var(--primary) - border-color var(--primary) - -</style> diff --git a/src/client/app/mobile/views/pages/welcome.vue b/src/client/app/mobile/views/pages/welcome.vue deleted file mode 100644 index 6cf4a36f90..0000000000 --- a/src/client/app/mobile/views/pages/welcome.vue +++ /dev/null @@ -1,310 +0,0 @@ -<template> -<div class="wgwfgvvimdjvhjfwxropcwksnzftjqes"> - <div class="banner" :style="{ backgroundImage: banner ? `url(${banner})` : null }"></div> - - <div> - <img svg-inline src="../../../../assets/title.svg" alt="Misskey"> - <p class="host">{{ host }}</p> - <div class="about"> - <h2>{{ name || 'Misskey' }}</h2> - <p v-html="description || this.$t('@.about')"></p> - <router-link class="signup" to="/signup">{{ $t('@.signup') }}</router-link> - </div> - <div class="signin"> - <a href="/signin" @click.prevent="signin()">{{ $t('@.signin') }}</a> - </div> - <div class="tl"> - <mk-welcome-timeline/> - </div> - <div class="hashtags"> - <mk-tag-cloud/> - </div> - <div class="photos"> - <div v-for="photo in photos" :style="`background-image: url(${photo.thumbnailUrl})`"></div> - </div> - <div class="stats" v-if="stats"> - <span><fa icon="user"/> {{ stats.originalUsersCount | number }}</span> - <span><fa icon="pencil-alt"/> {{ stats.originalNotesCount | number }}</span> - </div> - <div class="announcements" v-if="announcements && announcements.length > 0"> - <article v-for="announcement in announcements"> - <span class="title" v-html="announcement.title"></span> - <mfm :text="announcement.text"/> - <img v-if="announcement.image" :src="announcement.image" alt="" style="display: block; max-height: 120px; max-width: 100%;"/> - </article> - </div> - <article class="about-misskey"> - <h1>{{ $t('@.intro.title') }}</h1> - <p v-html="this.$t('@.intro.about')"></p> - <section> - <h2>{{ $t('@.intro.features') }}</h2> - <section> - <h3>{{ $t('@.intro.rich-contents') }}</h3> - <div class="image"><img src="/assets/about/post.png" alt=""></div> - <p v-html="this.$t('@.intro.rich-contents-desc')"></p> - </section> - <section> - <h3>{{ $t('@.intro.reaction') }}</h3> - <div class="image"><img src="/assets/about/reaction.png" alt=""></div> - <p v-html="this.$t('@.intro.reaction-desc')"></p> - </section> - <section> - <h3>{{ $t('@.intro.ui') }}</h3> - <div class="image"><img src="/assets/about/ui.png" alt=""></div> - <p v-html="this.$t('@.intro.ui-desc')"></p> - </section> - <section> - <h3>{{ $t('@.intro.drive') }}</h3> - <div class="image"><img src="/assets/about/drive.png" alt=""></div> - <p v-html="this.$t('@.intro.drive-desc')"></p> - </section> - </section> - <p v-html="this.$t('@.intro.outro')"></p> - </article> - <div class="info" v-if="meta"> - <p>Version: <b>{{ meta.version }}</b></p> - <p>Maintainer: <b><a :href="'mailto:' + meta.maintainerEmail" target="_blank">{{ meta.maintainerName }}</a></b></p> - </div> - <footer> - <small>{{ copyright }}</small> - </footer> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { copyright, host } from '../../../config'; -import { concat } from '../../../../../prelude/array'; -import { toUnicode } from 'punycode'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/welcome.vue'), - data() { - return { - meta: null, - copyright, - stats: null, - banner: null, - host: toUnicode(host), - name: null, - description: '', - photos: [], - announcements: [] - }; - }, - created() { - this.$root.getMeta().then(meta => { - this.meta = meta; - this.name = meta.name; - this.description = meta.description; - this.announcements = meta.announcements; - this.banner = meta.bannerUrl; - }); - - this.$root.api('stats').then(stats => { - this.stats = stats; - }); - - const image = [ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/apng', - 'image/vnd.mozilla.apng', - ]; - - this.$root.api('notes/local-timeline', { - fileType: image, - excludeNsfw: true, - limit: 6 - }).then((notes: any[]) => { - const files = concat(notes.map((n: any): any[] => n.files)); - this.photos = files.filter(f => image.includes(f.type)).slice(0, 6); - }); - }, - methods: { - signin() { - this.$root.dialog({ - type: 'signin' - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.wgwfgvvimdjvhjfwxropcwksnzftjqes - text-align center - - > .banner - position absolute - top 0 - left 0 - width 100% - height 300px - background-position center - background-size cover - opacity 0.7 - - &:after - content "" - display block - position absolute - bottom 0 - left 0 - width 100% - height 100px - background linear-gradient(transparent, var(--bg)) - - > div:not(.banner) - padding 32px - margin 0 auto - max-width 500px - - > svg - display block - width 200px - height 50px - margin 0 auto - - > .host - display block - text-align center - padding 6px 12px - line-height 32px - font-weight bold - color #333 - background rgba(#000, 0.035) - border-radius 6px - - > .about - margin-top 16px - padding 16px - color var(--text) - background var(--face) - border-radius 6px - - > h2 - margin 0 - - > p - margin 8px - - > .signup - font-weight bold - - > .signin - margin 16px 0 - - > .tl - margin 16px 0 - - > * - max-height 300px - border-radius 6px - overflow auto - -webkit-overflow-scrolling touch - - > .hashtags - padding 0 8px - height 200px - - > .photos - display grid - grid-template-rows 1fr 1fr 1fr - grid-template-columns 1fr 1fr - gap 8px - height 300px - margin-top 16px - - > div - border-radius 4px - background-position center center - background-size cover - - > .stats - margin 16px 0 - padding 8px - font-size 14px - color var(--text) - background rgba(#000, 0.1) - border-radius 6px - - > * - margin 0 8px - - > .announcements - margin 16px 0 - - > article - background var(--mobileAnnouncement) - border-radius 6px - color var(--mobileAnnouncementFg) - padding 16px - margin 8px 0 - font-size 12px - - > .title - font-weight bold - - > .about-misskey - margin 16px 0 - padding 32px - font-size 14px - background var(--face) - border-radius 6px - overflow hidden - color var(--text) - - > h1 - margin 0 - - & + p - margin-top 8px - - > p:last-child - margin-bottom 0 - - > section - > h2 - border-bottom 1px solid var(--faceDivider) - - > section - margin-bottom 16px - padding-bottom 16px - border-bottom 1px solid var(--faceDivider) - - > h3 - margin-bottom 8px - - > p - margin-bottom 0 - - > .image - > img - display block - width 100% - height 120px - object-fit cover - - > .info - padding 16px 0 - border solid 2px rgba(0, 0, 0, 0.1) - border-radius 8px - color var(--text) - - > * - margin 0 16px - - > footer - text-align center - color var(--text) - - > small - display block - margin 16px 0 0 0 - opacity 0.7 - -</style> diff --git a/src/client/app/mobile/views/pages/widgets.vue b/src/client/app/mobile/views/pages/widgets.vue deleted file mode 100644 index 19df613b3a..0000000000 --- a/src/client/app/mobile/views/pages/widgets.vue +++ /dev/null @@ -1,192 +0,0 @@ -<template> -<mk-ui> - <template #header><span style="margin-right:4px;"><fa icon="home"/></span>{{ $t('dashboard') }}</template> - <template #func> - <button @click="customizing = !customizing"><fa icon="cog"/></button> - </template> - <main> - <template v-if="customizing"> - <header> - <select v-model="widgetAdderSelected"> - <option value="profile">{{ $t('@.widgets.profile') }}</option> - <option value="analog-clock">{{ $t('@.widgets.analog-clock') }}</option> - <option value="calendar">{{ $t('@.widgets.calendar') }}</option> - <option value="activity">{{ $t('@.widgets.activity') }}</option> - <option value="rss">{{ $t('@.widgets.rss') }}</option> - <option value="photo-stream">{{ $t('@.widgets.photo-stream') }}</option> - <option value="slideshow">{{ $t('@.widgets.slideshow') }}</option> - <option value="hashtags">{{ $t('@.widgets.hashtags') }}</option> - <option value="posts-monitor">{{ $t('@.widgets.posts-monitor') }}</option> - <option value="version">{{ $t('@.widgets.version') }}</option> - <option value="server">{{ $t('@.widgets.server') }}</option> - <option value="queue">{{ $t('@.widgets.queue') }}</option> - <option value="memo">{{ $t('@.widgets.memo') }}</option> - <option value="nav">{{ $t('@.widgets.nav') }}</option> - <option value="tips">{{ $t('@.widgets.tips') }}</option> - </select> - <button @click="addWidget">{{ $t('add-widget') }}</button> - <p><a @click="hint">{{ $t('customization-tips') }}</a></p> - </header> - <x-draggable - :list="widgets" - handle=".handle" - animation="150" - @sort="onWidgetSort" - > - <div v-for="widget in widgets" class="customize-container" :key="widget.id"> - <header> - <span class="handle"><fa icon="bars"/></span>{{ widget.name }}<button class="remove" @click="removeWidget(widget)"><fa icon="times"/></button> - </header> - <div @click="widgetFunc(widget.id)"> - <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" platform="mobile"/> - </div> - </div> - </x-draggable> - </template> - <template v-else> - <component class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" platform="mobile"/> - </template> - </main> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import * as XDraggable from 'vuedraggable'; -import { v4 as uuid } from 'uuid'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/widgets.vue'), - components: { - XDraggable - }, - - data() { - return { - showNav: false, - customizing: false, - widgetAdderSelected: null - }; - }, - - computed: { - widgets(): any[] { - return this.$store.getters.mobileHome || []; - } - }, - - created() { - if (this.widgets.length == 0) { - this.$store.commit('setMobileHome', [{ - name: 'calendar', - id: 'a', data: {} - }, { - name: 'activity', - id: 'b', data: {} - }, { - name: 'rss', - id: 'c', data: {} - }, { - name: 'photo-stream', - id: 'd', data: {} - }, { - name: 'nav', - id: 'f', data: {} - }, { - name: 'version', - id: 'g', data: {} - }]); - } - }, - - mounted() { - document.title = this.$root.instanceName; - }, - - methods: { - hint() { - this.$root.dialog({ - type: 'info', - text: this.$t('widgets-hints') - }); - }, - - widgetFunc(id) { - const w = this.$refs[id][0]; - if (w.func) w.func(); - }, - - onWidgetSort() { - this.saveHome(); - }, - - addWidget() { - if(this.widgetAdderSelected == null) return; - - this.$store.commit('addMobileHomeWidget', { - name: this.widgetAdderSelected, - id: uuid(), - data: {} - }); - }, - - removeWidget(widget) { - this.$store.commit('removeMobileHomeWidget', widget); - }, - - saveHome() { - this.$store.commit('setMobileHome', this.widgets); - } - } -}); -</script> - -<style lang="stylus" scoped> -main - margin 0 auto - padding 8px - max-width 500px - width 100% - - @media (min-width 500px) - padding 16px 8px - - @media (min-width 600px) - padding 32px 8px - - > header - padding 8px - background #fff - - .widget - margin-bottom 8px - - @media (min-width 600px) - margin-bottom 16px - - .customize-container - margin 8px - background #fff - - > header - line-height 32px - background #eee - - > .handle - padding 0 8px - - > .remove - position absolute - top 0 - right 0 - padding 0 8px - line-height 32px - - > div - padding 8px - - > * - pointer-events none - -</style> diff --git a/src/client/app/mobile/views/widgets/activity.vue b/src/client/app/mobile/views/widgets/activity.vue deleted file mode 100644 index 047784deac..0000000000 --- a/src/client/app/mobile/views/widgets/activity.vue +++ /dev/null @@ -1,38 +0,0 @@ -<template> -<div class="mkw-activity"> - <ui-container :show-header="!props.compact"> - <template #header><fa icon="chart-bar"/>{{ $t('activity') }}</template> - <div :class="$style.body"> - <x-activity :user="$store.state.i"/> - </div> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; - -export default define({ - name: 'activity', - props: () => ({ - compact: false - }) -}).extend({ - i18n: i18n(), - components: { - XActivity: () => import('../../../common/views/components/activity.vue').then(m => m.default) - }, - methods: { - func() { - this.props.compact = !this.props.compact; - this.save(); - } - } -}); -</script> - -<style lang="stylus" module> -.body - padding 8px -</style> diff --git a/src/client/app/mobile/views/widgets/index.ts b/src/client/app/mobile/views/widgets/index.ts deleted file mode 100644 index 4de912b64c..0000000000 --- a/src/client/app/mobile/views/widgets/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import Vue from 'vue'; - -import wActivity from './activity.vue'; -import wProfile from './profile.vue'; - -Vue.component('mkw-activity', wActivity); -Vue.component('mkw-profile', wProfile); diff --git a/src/client/app/mobile/views/widgets/profile.vue b/src/client/app/mobile/views/widgets/profile.vue deleted file mode 100644 index d4ccc87e57..0000000000 --- a/src/client/app/mobile/views/widgets/profile.vue +++ /dev/null @@ -1,65 +0,0 @@ -<template> -<div class="mkw-profile"> - <ui-container> - <div :class="$style.banner" - :style="$store.state.i.bannerUrl ? `background-image: url(${$store.state.i.bannerUrl})` : ''" - ></div> - <img :class="$style.avatar" - :src="$store.state.i.avatarUrl" - alt="avatar" - /> - <router-link :class="$style.name" :to="$store.state.i | userPage"> - <mk-user-name :user="$store.state.i"/> - </router-link> - </ui-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; - -export default define({ - name: 'profile' -}); -</script> - -<style lang="stylus" module> -.banner - height 100px - background-color #f5f5f5 - background-size cover - background-position center - cursor pointer - -.banner:before - content "" - display block - width 100% - height 100% - background rgba(#000, 0.5) - -.avatar - display block - position absolute - width 58px - height 58px - margin 0 - vertical-align bottom - top ((100px - 58px) / 2) - left ((100px - 58px) / 2) - border none - border-radius 100% - box-shadow 0 0 16px rgba(#000, 0.5) - -.name - display block - position absolute - top 0 - left 92px - margin 0 - line-height 100px - color #fff - font-weight bold - text-shadow 0 0 8px rgba(#000, 0.5) - -</style> diff --git a/src/client/app/reset.styl b/src/client/app/reset.styl deleted file mode 100644 index 614f29a835..0000000000 --- a/src/client/app/reset.styl +++ /dev/null @@ -1,37 +0,0 @@ -input - min-width 0 - -input:not([type]) -input[type='text'] -input[type='password'] -input[type='search'] -input[type='email'] -textarea -button -progress - -webkit-appearance none - -moz-appearance none - appearance none - box-shadow none - -textarea - font-family Roboto, HelveticaNeue, Arial, sans-serif - -button - margin 0 - background transparent - border none - cursor pointer - color inherit - touch-action manipulation - - * - pointer-events none - user-select none - - &[disabled] - cursor default - -pre - overflow auto - white-space pre diff --git a/src/client/app/safe.js b/src/client/app/safe.js deleted file mode 100644 index 88c603f6b9..0000000000 --- a/src/client/app/safe.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * ブラウザの検証 - */ - -// Detect an old browser -if (!('fetch' in window)) { - alert( - 'お使いのブラウザ(またはOS)のバージョンが旧式のため、Misskeyを動作させることができません。' + - 'バージョンを最新のものに更新するか、別のブラウザをお試しください。' + - '\n\n' + - 'Your browser (or your OS) seems outdated. ' + - 'To run Misskey, please update your browser to latest version or try other browsers.'); -} diff --git a/src/client/app/store.ts b/src/client/app/store.ts deleted file mode 100644 index fd3aceb728..0000000000 --- a/src/client/app/store.ts +++ /dev/null @@ -1,463 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import createPersistedState from 'vuex-persistedstate'; -import * as nestedProperty from 'nested-property'; - -import MiOS from './mios'; -import { erase } from '../../prelude/array'; -import getNoteSummary from '../../misc/get-note-summary'; - -const defaultSettings = { - keepCw: false, - tagTimelines: [], - fetchOnScroll: true, - remainDeletedNote: false, - showPostFormOnTopOfTl: false, - suggestRecentHashtags: true, - showClockOnHeader: true, - circleIcons: true, - contrastedAcct: true, - showFullAcct: false, - showVia: true, - showReplyTarget: true, - showMyRenotes: true, - showRenotedMyNotes: true, - showLocalRenotes: true, - loadRemoteMedia: true, - disableViaMobile: false, - memo: null, - iLikeSushi: false, - rememberNoteVisibility: false, - defaultNoteVisibility: 'public', - wallpaper: null, - webSearchEngine: 'https://www.google.com/?#q={{query}}', - mutedWords: [], - gamesReversiShowBoardLabels: false, - gamesReversiUseAvatarStones: true, - disableAnimatedMfm: false, - homeProfiles: {}, - mobileHomeProfiles: {}, - deckProfiles: {}, - uploadFolder: null, - pastedFileName: 'yyyy-MM-dd HH-mm-ss [{{number}}]', - pasteDialog: false, - reactions: ['like', 'love', 'laugh', 'hmm', 'surprise', 'congrats', 'angry', 'confused', 'rip', 'pudding'] -}; - -const defaultDeviceSettings = { - homeProfile: 'Default', - mobileHomeProfile: 'Default', - deckProfile: 'Default', - deckMode: false, - deckColumnAlign: 'center', - deckColumnWidth: 'normal', - useShadow: false, - roundedCorners: true, - reduceMotion: false, - darkmode: true, - darkTheme: 'bb5a8287-a072-4b0a-8ae5-ea2a0d33f4f2', - lightTheme: 'light', - lineWidth: 1, - fontSize: 0, - themes: [], - enableSounds: true, - soundVolume: 0.5, - mediaVolume: 0.5, - lang: null, - appTypeForce: 'auto', - debug: false, - lightmode: false, - loadRawImages: false, - alwaysShowNsfw: false, - postStyle: 'standard', - navbar: 'top', - mobileNotificationPosition: 'bottom', - useOsDefaultEmojis: false, - disableShowingAnimatedImages: false, - expandUsersPhotos: true, - expandUsersActivity: true, - enableMobileQuickNotificationView: false, - roomGraphicsQuality: 'medium', - roomUseOrthographicCamera: true, - activeEmojiCategoryName: undefined, - recentEmojis: [], -}; - -export default (os: MiOS) => new Vuex.Store({ - plugins: [createPersistedState({ - paths: ['i', 'device', 'settings'] - })], - - state: { - i: null, - indicate: false, - uiHeaderHeight: 0, - behindNotes: [] - }, - - getters: { - isSignedIn: state => state.i != null, - - home: state => state.settings.homeProfiles[state.device.homeProfile], - - mobileHome: state => state.settings.mobileHomeProfiles[state.device.mobileHomeProfile], - - deck: state => state.settings.deckProfiles[state.device.deckProfile], - }, - - mutations: { - updateI(state, x) { - state.i = x; - }, - - updateIKeyValue(state, x) { - state.i[x.key] = x.value; - }, - - indicate(state, x) { - state.indicate = x; - }, - - setUiHeaderHeight(state, height) { - state.uiHeaderHeight = height; - }, - - pushBehindNote(state, note) { - if (note.userId === state.i.id) return; - if (state.behindNotes.some(n => n.id === note.id)) return; - state.behindNotes.push(note); - document.title = `(${state.behindNotes.length}) ${getNoteSummary(note)}`; - }, - - clearBehindNotes(state) { - state.behindNotes = []; - document.title = os.instanceName; - }, - - setHome(state, data) { - Vue.set(state.settings.homeProfiles, state.device.homeProfile, data); - os.store.dispatch('settings/updateHomeProfile'); - }, - - setDeck(state, data) { - Vue.set(state.settings.deckProfiles, state.device.deckProfile, data); - os.store.dispatch('settings/updateDeckProfile'); - }, - - addHomeWidget(state, widget) { - state.settings.homeProfiles[state.device.homeProfile].unshift(widget); - os.store.dispatch('settings/updateHomeProfile'); - }, - - setMobileHome(state, data) { - Vue.set(state.settings.mobileHomeProfiles, state.device.mobileHomeProfile, data); - os.store.dispatch('settings/updateMobileHomeProfile'); - }, - - updateWidget(state, x) { - let w; - - //#region Desktop home - const home = state.settings.homeProfiles[state.device.homeProfile]; - if (home) { - w = home.find(w => w.id == x.id); - if (w) { - w.data = x.data; - os.store.dispatch('settings/updateHomeProfile'); - } - } - //#endregion - - //#region Mobile home - const mobileHome = state.settings.mobileHomeProfiles[state.device.mobileHomeProfile]; - if (mobileHome) { - w = mobileHome.find(w => w.id == x.id); - if (w) { - w.data = x.data; - os.store.dispatch('settings/updateMobileHomeProfile'); - } - } - //#endregion - }, - - addMobileHomeWidget(state, widget) { - state.settings.mobileHomeProfiles[state.device.mobileHomeProfile].unshift(widget); - os.store.dispatch('settings/updateMobileHomeProfile'); - }, - - removeMobileHomeWidget(state, widget) { - Vue.set('state.settings.mobileHomeProfiles', state.device.mobileHomeProfile, state.settings.mobileHomeProfiles[state.device.mobileHomeProfile].filter(w => w.id != widget.id)); - os.store.dispatch('settings/updateMobileHomeProfile'); - }, - - addDeckColumn(state, column) { - const deck = state.settings.deckProfiles[state.device.deckProfile]; - if (column.name == undefined) column.name = null; - deck.columns.push(column); - deck.layout.push([column.id]); - os.store.dispatch('settings/updateDeckProfile'); - }, - - removeDeckColumn(state, id) { - const deck = state.settings.deckProfiles[state.device.deckProfile]; - deck.columns = deck.columns.filter(c => c.id != id); - deck.layout = deck.layout.map(ids => erase(id, ids)); - deck.layout = deck.layout.filter(ids => ids.length > 0); - os.store.dispatch('settings/updateDeckProfile'); - }, - - swapDeckColumn(state, x) { - const deck = state.settings.deckProfiles[state.device.deckProfile]; - const a = x.a; - const b = x.b; - const aX = deck.layout.findIndex(ids => ids.indexOf(a) != -1); - const aY = deck.layout[aX].findIndex(id => id == a); - const bX = deck.layout.findIndex(ids => ids.indexOf(b) != -1); - const bY = deck.layout[bX].findIndex(id => id == b); - deck.layout[aX][aY] = b; - deck.layout[bX][bY] = a; - os.store.dispatch('settings/updateDeckProfile'); - }, - - swapLeftDeckColumn(state, id) { - const deck = state.settings.deckProfiles[state.device.deckProfile]; - deck.layout.some((ids, i) => { - if (ids.indexOf(id) != -1) { - const left = deck.layout[i - 1]; - if (left) { - // https://vuejs.org/v2/guide/list.html#Caveats - //state.deck.layout[i - 1] = state.deck.layout[i]; - //state.deck.layout[i] = left; - deck.layout.splice(i - 1, 1, deck.layout[i]); - deck.layout.splice(i, 1, left); - } - return true; - } - }); - os.store.dispatch('settings/updateDeckProfile'); - }, - - swapRightDeckColumn(state, id) { - const deck = state.settings.deckProfiles[state.device.deckProfile]; - deck.layout.some((ids, i) => { - if (ids.indexOf(id) != -1) { - const right = deck.layout[i + 1]; - if (right) { - // https://vuejs.org/v2/guide/list.html#Caveats - //state.deck.layout[i + 1] = state.deck.layout[i]; - //state.deck.layout[i] = right; - deck.layout.splice(i + 1, 1, deck.layout[i]); - deck.layout.splice(i, 1, right); - } - return true; - } - }); - os.store.dispatch('settings/updateDeckProfile'); - }, - - swapUpDeckColumn(state, id) { - const deck = state.settings.deckProfiles[state.device.deckProfile]; - const ids = deck.layout.find(ids => ids.indexOf(id) != -1); - ids.some((x, i) => { - if (x == id) { - const up = ids[i - 1]; - if (up) { - // https://vuejs.org/v2/guide/list.html#Caveats - //ids[i - 1] = id; - //ids[i] = up; - ids.splice(i - 1, 1, id); - ids.splice(i, 1, up); - } - return true; - } - }); - os.store.dispatch('settings/updateDeckProfile'); - }, - - swapDownDeckColumn(state, id) { - const deck = state.settings.deckProfiles[state.device.deckProfile]; - const ids = deck.layout.find(ids => ids.indexOf(id) != -1); - ids.some((x, i) => { - if (x == id) { - const down = ids[i + 1]; - if (down) { - // https://vuejs.org/v2/guide/list.html#Caveats - //ids[i + 1] = id; - //ids[i] = down; - ids.splice(i + 1, 1, id); - ids.splice(i, 1, down); - } - return true; - } - }); - os.store.dispatch('settings/updateDeckProfile'); - }, - - stackLeftDeckColumn(state, id) { - const deck = state.settings.deckProfiles[state.device.deckProfile]; - const i = deck.layout.findIndex(ids => ids.indexOf(id) != -1); - deck.layout = deck.layout.map(ids => erase(id, ids)); - const left = deck.layout[i - 1]; - if (left) deck.layout[i - 1].push(id); - deck.layout = deck.layout.filter(ids => ids.length > 0); - os.store.dispatch('settings/updateDeckProfile'); - }, - - popRightDeckColumn(state, id) { - const deck = state.settings.deckProfiles[state.device.deckProfile]; - const i = deck.layout.findIndex(ids => ids.indexOf(id) != -1); - deck.layout = deck.layout.map(ids => erase(id, ids)); - deck.layout.splice(i + 1, 0, [id]); - deck.layout = deck.layout.filter(ids => ids.length > 0); - os.store.dispatch('settings/updateDeckProfile'); - }, - - addDeckWidget(state, x) { - const deck = state.settings.deckProfiles[state.device.deckProfile]; - const column = deck.columns.find(c => c.id == x.id); - if (column == null) return; - column.widgets.unshift(x.widget); - os.store.dispatch('settings/updateDeckProfile'); - }, - - removeDeckWidget(state, x) { - const deck = state.settings.deckProfiles[state.device.deckProfile]; - const column = deck.columns.find(c => c.id == x.id); - if (column == null) return; - column.widgets = column.widgets.filter(w => w.id != x.widget.id); - os.store.dispatch('settings/updateDeckProfile'); - }, - - renameDeckColumn(state, x) { - const deck = state.settings.deckProfiles[state.device.deckProfile]; - const column = deck.columns.find(c => c.id == x.id); - if (column == null) return; - column.name = x.name; - os.store.dispatch('settings/updateDeckProfile'); - }, - - updateDeckColumn(state, x) { - const deck = state.settings.deckProfiles[state.device.deckProfile]; - let column = deck.columns.find(c => c.id == x.id); - if (column == null) return; - column = x; - os.store.dispatch('settings/updateDeckProfile'); - } - }, - - actions: { - login(ctx, i) { - ctx.commit('updateI', i); - ctx.dispatch('settings/merge', i.clientData); - }, - - logout(ctx) { - ctx.commit('updateI', null); - document.cookie = `i=; max-age=0; domain=${document.location.hostname}`; - localStorage.removeItem('i'); - }, - - mergeMe(ctx, me) { - for (const [key, value] of Object.entries(me)) { - ctx.commit('updateIKeyValue', { key, value }); - } - - if (me.clientData) { - ctx.dispatch('settings/merge', me.clientData); - } - }, - }, - - modules: { - device: { - namespaced: true, - - state: defaultDeviceSettings, - - mutations: { - set(state, x: { key: string; value: any }) { - state[x.key] = x.value; - }, - - setTl(state, x) { - state.tl = { - src: x.src, - arg: x.arg - }; - }, - - setVisibility(state, visibility) { - state.visibility = visibility; - }, - } - }, - - settings: { - namespaced: true, - - state: defaultSettings, - - mutations: { - set(state, x: { key: string; value: any }) { - nestedProperty.set(state, x.key, x.value); - }, - }, - - actions: { - merge(ctx, settings) { - if (settings == null) return; - for (const [key, value] of Object.entries(settings)) { - ctx.commit('set', { key, value }); - } - }, - - set(ctx, x) { - ctx.commit('set', x); - - if (ctx.rootGetters.isSignedIn) { - os.api('i/update-client-setting', { - name: x.key, - value: x.value - }); - } - }, - - updateHomeProfile(ctx) { - const profiles = ctx.state.homeProfiles; - ctx.commit('set', { - key: 'homeProfiles', - value: profiles - }); - os.api('i/update-client-setting', { - name: 'homeProfiles', - value: profiles - }); - }, - - updateMobileHomeProfile(ctx) { - const profiles = ctx.state.mobileHomeProfiles; - ctx.commit('set', { - key: 'mobileHomeProfiles', - value: profiles - }); - os.api('i/update-client-setting', { - name: 'mobileHomeProfiles', - value: profiles - }); - }, - - updateDeckProfile(ctx) { - const profiles = ctx.state.deckProfiles; - ctx.commit('set', { - key: 'deckProfiles', - value: profiles - }); - os.api('i/update-client-setting', { - name: 'deckProfiles', - value: profiles - }); - }, - } - } - } -}); diff --git a/src/client/app/theme.ts b/src/client/app/theme.ts deleted file mode 100644 index b16fcdff4b..0000000000 --- a/src/client/app/theme.ts +++ /dev/null @@ -1,127 +0,0 @@ -import * as tinycolor from 'tinycolor2'; - -export type Theme = { - id: string; - name: string; - author: string; - desc?: string; - base?: 'dark' | 'light'; - vars: { [key: string]: string }; - props: { [key: string]: string }; -}; - -export const lightTheme: Theme = require('../themes/light.json5'); -export const darkTheme: Theme = require('../themes/dark.json5'); -export const lavenderTheme: Theme = require('../themes/lavender.json5'); -export const futureTheme: Theme = require('../themes/future.json5'); -export const halloweenTheme: Theme = require('../themes/halloween.json5'); -export const cafeTheme: Theme = require('../themes/cafe.json5'); -export const japaneseSushiSetTheme: Theme = require('../themes/japanese-sushi-set.json5'); -export const gruvboxDarkTheme: Theme = require('../themes/gruvbox-dark.json5'); -export const monokaiTheme: Theme = require('../themes/monokai.json5'); -export const vividTheme: Theme = require('../themes/vivid.json5'); -export const rainyTheme: Theme = require('../themes/rainy.json5'); -export const mauveTheme: Theme = require('../themes/mauve.json5'); -export const grayTheme: Theme = require('../themes/gray.json5'); -export const tweetDeckTheme: Theme = require('../themes/tweet-deck.json5'); - -export const builtinThemes = [ - lightTheme, - darkTheme, - lavenderTheme, - futureTheme, - halloweenTheme, - cafeTheme, - japaneseSushiSetTheme, - gruvboxDarkTheme, - monokaiTheme, - vividTheme, - rainyTheme, - mauveTheme, - grayTheme, - tweetDeckTheme, -]; - -export function applyTheme(theme: Theme, persisted = true) { - document.documentElement.classList.add('changing-theme'); - - setTimeout(() => { - document.documentElement.classList.remove('changing-theme'); - }, 1000); - - // Deep copy - const _theme = JSON.parse(JSON.stringify(theme)); - - if (_theme.base) { - const base = [lightTheme, darkTheme].find(x => x.id == _theme.base); - _theme.vars = Object.assign({}, base.vars, _theme.vars); - _theme.props = Object.assign({}, base.props, _theme.props); - } - - const props = compile(_theme); - - for (const [k, v] of Object.entries(props)) { - document.documentElement.style.setProperty(`--${k}`, v.toString()); - } - - if (persisted) { - localStorage.setItem('theme', JSON.stringify(props)); - } -} - -function compile(theme: Theme): { [key: string]: string } { - function getColor(code: string): tinycolor.Instance { - // ref - if (code[0] == '@') { - return getColor(theme.props[code.substr(1)]); - } - if (code[0] == '$') { - return getColor(theme.vars[code.substr(1)]); - } - - // func - if (code[0] == ':') { - const parts = code.split('<'); - const func = parts.shift().substr(1); - const arg = parseFloat(parts.shift()); - const color = getColor(parts.join('<')); - - switch (func) { - case 'darken': return color.darken(arg); - case 'lighten': return color.lighten(arg); - case 'alpha': return color.setAlpha(arg); - } - } - - return tinycolor(code); - } - - const props = {}; - - for (const [k, v] of Object.entries(theme.props)) { - props[k] = genValue(getColor(v)); - } - - const primary = getColor(props['primary']); - - for (let i = 1; i < 10; i++) { - const color = primary.clone().setAlpha(i / 10); - props['primaryAlpha0' + i] = genValue(color); - } - - for (let i = 5; i < 100; i += 5) { - const color = primary.clone().lighten(i); - props['primaryLighten' + i] = genValue(color); - } - - for (let i = 5; i < 100; i += 5) { - const color = primary.clone().darken(i); - props['primaryDarken' + i] = genValue(color); - } - - return props; -} - -function genValue(c: tinycolor.Instance): string { - return c.toRgbString(); -} diff --git a/src/client/assets/error.jpg b/src/client/assets/error.jpg Binary files differdeleted file mode 100644 index 24d92f3803..0000000000 --- a/src/client/assets/error.jpg +++ /dev/null diff --git a/src/client/assets/fedi.jpg b/src/client/assets/fedi.jpg Binary files differdeleted file mode 100644 index cbf3748eb8..0000000000 --- a/src/client/assets/fedi.jpg +++ /dev/null diff --git a/src/client/assets/flush.html b/src/client/assets/flush.html deleted file mode 100644 index 27725268f9..0000000000 --- a/src/client/assets/flush.html +++ /dev/null @@ -1,16 +0,0 @@ -<!DOCTYPE html> - -<html> - <head> - <meta charset="utf-8"> - <title>Misskeyのリカバリ</title> - <script> - const yn = location.search === '?force' || window.confirm('キャッシュをクリアしますか?\n\nDo you want to clear caches?'); - if (yn) { - localStorage.setItem('shouldFlush', 'true'); - } - - location.href = '/'; - </script> - </head> -</html> diff --git a/src/client/assets/manifest.json b/src/client/assets/manifest.json index 895afbed36..f5a1d47a8a 100644 --- a/src/client/assets/manifest.json +++ b/src/client/assets/manifest.json @@ -4,39 +4,14 @@ "start_url": "/", "display": "standalone", "background_color": "#313a42", - "theme_color": "#fb4e4e", + "theme_color": "#86b300", "icons": [ { - "src": "/assets/icons/16.png", - "sizes": "16x16", - "type": "image/png" - }, - { - "src": "/assets/icons/32.png", - "sizes": "32x32", - "type": "image/png" - }, - { - "src": "/assets/icons/64.png", - "sizes": "64x64", - "type": "image/png" - }, - { - "src": "/assets/icons/128.png", - "sizes": "128x128", - "type": "image/png" - }, - { "src": "/assets/icons/192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "/assets/icons/256.png", - "sizes": "256x256", - "type": "image/png" - }, - { "src": "/assets/icons/512.png", "sizes": "512x512", "type": "image/png" diff --git a/src/client/assets/message.mp3 b/src/client/assets/message.mp3 Binary files differdeleted file mode 100644 index 6427744475..0000000000 --- a/src/client/assets/message.mp3 +++ /dev/null diff --git a/src/client/assets/misskey-php-like-logo.png b/src/client/assets/misskey-php-like-logo.png Binary files differdeleted file mode 100644 index 882ef6708b..0000000000 --- a/src/client/assets/misskey-php-like-logo.png +++ /dev/null diff --git a/src/client/assets/pointer.png b/src/client/assets/pointer.png Binary files differdeleted file mode 100644 index 255e0c8a4f..0000000000 --- a/src/client/assets/pointer.png +++ /dev/null diff --git a/src/client/assets/post.mp3 b/src/client/assets/post.mp3 Binary files differdeleted file mode 100644 index d3da88a933..0000000000 --- a/src/client/assets/post.mp3 +++ /dev/null diff --git a/src/client/assets/redoc.html b/src/client/assets/redoc.html deleted file mode 100644 index 9803464cb1..0000000000 --- a/src/client/assets/redoc.html +++ /dev/null @@ -1,24 +0,0 @@ -<!DOCTYPE html> -<html> - <head> - <title>Misskey API</title> - <!-- needed for adaptive design --> - <meta charset="utf-8"/> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet"> - - <!-- - ReDoc doesn't change outer page styles - --> - <style> - body { - margin: 0; - padding: 0; - } - </style> - </head> - <body> - <redoc spec-url='/api.json'></redoc> - <script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script> - </body> -</html> diff --git a/src/client/assets/reversi-put-me.mp3 b/src/client/assets/reversi-put-me.mp3 Binary files differdeleted file mode 100644 index 4e0e72091c..0000000000 --- a/src/client/assets/reversi-put-me.mp3 +++ /dev/null diff --git a/src/client/assets/reversi-put-you.mp3 b/src/client/assets/reversi-put-you.mp3 Binary files differdeleted file mode 100644 index 9244189c2d..0000000000 --- a/src/client/assets/reversi-put-you.mp3 +++ /dev/null diff --git a/src/client/assets/room/furnitures/bed/bed.blend b/src/client/assets/room/furnitures/bed/bed.blend Binary files differdeleted file mode 100644 index 731df76d0c..0000000000 --- a/src/client/assets/room/furnitures/bed/bed.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/bed/bed.glb b/src/client/assets/room/furnitures/bed/bed.glb Binary files differdeleted file mode 100644 index f35ecb9ef4..0000000000 --- a/src/client/assets/room/furnitures/bed/bed.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/bin/bin.blend b/src/client/assets/room/furnitures/bin/bin.blend Binary files differdeleted file mode 100644 index 8d459a0869..0000000000 --- a/src/client/assets/room/furnitures/bin/bin.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/bin/bin.glb b/src/client/assets/room/furnitures/bin/bin.glb Binary files differdeleted file mode 100644 index b45f203802..0000000000 --- a/src/client/assets/room/furnitures/bin/bin.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/book/book.blend b/src/client/assets/room/furnitures/book/book.blend Binary files differdeleted file mode 100644 index 0d4899d4ae..0000000000 --- a/src/client/assets/room/furnitures/book/book.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/book/book.glb b/src/client/assets/room/furnitures/book/book.glb Binary files differdeleted file mode 100644 index 546893da06..0000000000 --- a/src/client/assets/room/furnitures/book/book.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/book2/barcode.png b/src/client/assets/room/furnitures/book2/barcode.png Binary files differdeleted file mode 100644 index 37cfe5add3..0000000000 --- a/src/client/assets/room/furnitures/book2/barcode.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/book2/book2.blend b/src/client/assets/room/furnitures/book2/book2.blend Binary files differdeleted file mode 100644 index e0fdb48101..0000000000 --- a/src/client/assets/room/furnitures/book2/book2.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/book2/book2.glb b/src/client/assets/room/furnitures/book2/book2.glb Binary files differdeleted file mode 100644 index 2b26402f8c..0000000000 --- a/src/client/assets/room/furnitures/book2/book2.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/book2/texture.afdesign b/src/client/assets/room/furnitures/book2/texture.afdesign Binary files differdeleted file mode 100644 index b63771607a..0000000000 --- a/src/client/assets/room/furnitures/book2/texture.afdesign +++ /dev/null diff --git a/src/client/assets/room/furnitures/book2/texture.png b/src/client/assets/room/furnitures/book2/texture.png Binary files differdeleted file mode 100644 index 5aa84f0340..0000000000 --- a/src/client/assets/room/furnitures/book2/texture.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/book2/uv.png b/src/client/assets/room/furnitures/book2/uv.png Binary files differdeleted file mode 100644 index 61c4fb0400..0000000000 --- a/src/client/assets/room/furnitures/book2/uv.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/cardboard-box/cardboard-box.blend b/src/client/assets/room/furnitures/cardboard-box/cardboard-box.blend Binary files differdeleted file mode 100644 index 3a528de32a..0000000000 --- a/src/client/assets/room/furnitures/cardboard-box/cardboard-box.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/cardboard-box/cardboard-box.glb b/src/client/assets/room/furnitures/cardboard-box/cardboard-box.glb Binary files differdeleted file mode 100644 index bed372e94f..0000000000 --- a/src/client/assets/room/furnitures/cardboard-box/cardboard-box.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/cardboard-box2/cardboard-box2.blend b/src/client/assets/room/furnitures/cardboard-box2/cardboard-box2.blend Binary files differdeleted file mode 100644 index 5f146267ac..0000000000 --- a/src/client/assets/room/furnitures/cardboard-box2/cardboard-box2.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/cardboard-box2/cardboard-box2.glb b/src/client/assets/room/furnitures/cardboard-box2/cardboard-box2.glb Binary files differdeleted file mode 100644 index 85fcb5c0b6..0000000000 --- a/src/client/assets/room/furnitures/cardboard-box2/cardboard-box2.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/cardboard-box2/texture.png b/src/client/assets/room/furnitures/cardboard-box2/texture.png Binary files differdeleted file mode 100644 index e498d8f65b..0000000000 --- a/src/client/assets/room/furnitures/cardboard-box2/texture.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/cardboard-box2/uv.png b/src/client/assets/room/furnitures/cardboard-box2/uv.png Binary files differdeleted file mode 100644 index d547843ee0..0000000000 --- a/src/client/assets/room/furnitures/cardboard-box2/uv.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/cardboard-box3/cardboard-box3.blend b/src/client/assets/room/furnitures/cardboard-box3/cardboard-box3.blend Binary files differdeleted file mode 100644 index 00681a3cfd..0000000000 --- a/src/client/assets/room/furnitures/cardboard-box3/cardboard-box3.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/cardboard-box3/cardboard-box3.glb b/src/client/assets/room/furnitures/cardboard-box3/cardboard-box3.glb Binary files differdeleted file mode 100644 index 1ef0427689..0000000000 --- a/src/client/assets/room/furnitures/cardboard-box3/cardboard-box3.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/cardboard-box3/texture.png b/src/client/assets/room/furnitures/cardboard-box3/texture.png Binary files differdeleted file mode 100644 index 56c914cb9d..0000000000 --- a/src/client/assets/room/furnitures/cardboard-box3/texture.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/cardboard-box3/texture.xcf b/src/client/assets/room/furnitures/cardboard-box3/texture.xcf Binary files differdeleted file mode 100644 index 7ffb3e3439..0000000000 --- a/src/client/assets/room/furnitures/cardboard-box3/texture.xcf +++ /dev/null diff --git a/src/client/assets/room/furnitures/cardboard-box3/uv.png b/src/client/assets/room/furnitures/cardboard-box3/uv.png Binary files differdeleted file mode 100644 index 797ac509db..0000000000 --- a/src/client/assets/room/furnitures/cardboard-box3/uv.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/carpet-stripe/carpet-stripe.blend b/src/client/assets/room/furnitures/carpet-stripe/carpet-stripe.blend Binary files differdeleted file mode 100644 index 750343d4f0..0000000000 --- a/src/client/assets/room/furnitures/carpet-stripe/carpet-stripe.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/carpet-stripe/carpet-stripe.glb b/src/client/assets/room/furnitures/carpet-stripe/carpet-stripe.glb Binary files differdeleted file mode 100644 index 3066a69e35..0000000000 --- a/src/client/assets/room/furnitures/carpet-stripe/carpet-stripe.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/chair/chair.blend b/src/client/assets/room/furnitures/chair/chair.blend Binary files differdeleted file mode 100644 index 79c29a8401..0000000000 --- a/src/client/assets/room/furnitures/chair/chair.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/chair/chair.glb b/src/client/assets/room/furnitures/chair/chair.glb Binary files differdeleted file mode 100644 index 08ee1a0bb0..0000000000 --- a/src/client/assets/room/furnitures/chair/chair.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/chair2/chair2.blend b/src/client/assets/room/furnitures/chair2/chair2.blend Binary files differdeleted file mode 100644 index c6a1acd96f..0000000000 --- a/src/client/assets/room/furnitures/chair2/chair2.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/chair2/chair2.glb b/src/client/assets/room/furnitures/chair2/chair2.glb Binary files differdeleted file mode 100644 index 5ea2f3518b..0000000000 --- a/src/client/assets/room/furnitures/chair2/chair2.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/color-box/color-box.blend b/src/client/assets/room/furnitures/color-box/color-box.blend Binary files differdeleted file mode 100644 index f96a4ff766..0000000000 --- a/src/client/assets/room/furnitures/color-box/color-box.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/color-box/color-box.glb b/src/client/assets/room/furnitures/color-box/color-box.glb Binary files differdeleted file mode 100644 index 43f2abcae8..0000000000 --- a/src/client/assets/room/furnitures/color-box/color-box.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/corkboard/corkboard.blend b/src/client/assets/room/furnitures/corkboard/corkboard.blend Binary files differdeleted file mode 100644 index 9a7e1878cd..0000000000 --- a/src/client/assets/room/furnitures/corkboard/corkboard.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/corkboard/corkboard.glb b/src/client/assets/room/furnitures/corkboard/corkboard.glb Binary files differdeleted file mode 100644 index fee108fb91..0000000000 --- a/src/client/assets/room/furnitures/corkboard/corkboard.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/cube/cube.blend b/src/client/assets/room/furnitures/cube/cube.blend Binary files differdeleted file mode 100644 index 1af5bf40a9..0000000000 --- a/src/client/assets/room/furnitures/cube/cube.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/cube/cube.glb b/src/client/assets/room/furnitures/cube/cube.glb Binary files differdeleted file mode 100644 index 4ac8b6036d..0000000000 --- a/src/client/assets/room/furnitures/cube/cube.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/cup-noodle/cup-noodle.blend b/src/client/assets/room/furnitures/cup-noodle/cup-noodle.blend Binary files differdeleted file mode 100644 index 37ca8868c7..0000000000 --- a/src/client/assets/room/furnitures/cup-noodle/cup-noodle.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/cup-noodle/cup-noodle.glb b/src/client/assets/room/furnitures/cup-noodle/cup-noodle.glb Binary files differdeleted file mode 100644 index 58efb1b3b4..0000000000 --- a/src/client/assets/room/furnitures/cup-noodle/cup-noodle.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/cup-noodle/noodle.png b/src/client/assets/room/furnitures/cup-noodle/noodle.png Binary files differdeleted file mode 100644 index 1d74e0bbe7..0000000000 --- a/src/client/assets/room/furnitures/cup-noodle/noodle.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/desk/desk.blend b/src/client/assets/room/furnitures/desk/desk.blend Binary files differdeleted file mode 100644 index c88d01f0b2..0000000000 --- a/src/client/assets/room/furnitures/desk/desk.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/desk/desk.glb b/src/client/assets/room/furnitures/desk/desk.glb Binary files differdeleted file mode 100644 index 4a58513095..0000000000 --- a/src/client/assets/room/furnitures/desk/desk.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/energy-drink/energy-drink.blend b/src/client/assets/room/furnitures/energy-drink/energy-drink.blend Binary files differdeleted file mode 100644 index 65fc41273e..0000000000 --- a/src/client/assets/room/furnitures/energy-drink/energy-drink.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/energy-drink/energy-drink.glb b/src/client/assets/room/furnitures/energy-drink/energy-drink.glb Binary files differdeleted file mode 100644 index 7fb1c27836..0000000000 --- a/src/client/assets/room/furnitures/energy-drink/energy-drink.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/energy-drink/texture.afdesign b/src/client/assets/room/furnitures/energy-drink/texture.afdesign Binary files differdeleted file mode 100644 index 8c117a49b1..0000000000 --- a/src/client/assets/room/furnitures/energy-drink/texture.afdesign +++ /dev/null diff --git a/src/client/assets/room/furnitures/energy-drink/texture.png b/src/client/assets/room/furnitures/energy-drink/texture.png Binary files differdeleted file mode 100644 index 484ca0f96f..0000000000 --- a/src/client/assets/room/furnitures/energy-drink/texture.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/energy-drink/uv.png b/src/client/assets/room/furnitures/energy-drink/uv.png Binary files differdeleted file mode 100644 index 2a3f20c999..0000000000 --- a/src/client/assets/room/furnitures/energy-drink/uv.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/eraser/cover.png b/src/client/assets/room/furnitures/eraser/cover.png Binary files differdeleted file mode 100644 index 932a3fc62e..0000000000 --- a/src/client/assets/room/furnitures/eraser/cover.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/eraser/cover.psd b/src/client/assets/room/furnitures/eraser/cover.psd Binary files differdeleted file mode 100644 index c393337833..0000000000 --- a/src/client/assets/room/furnitures/eraser/cover.psd +++ /dev/null diff --git a/src/client/assets/room/furnitures/eraser/eraser-uv.png b/src/client/assets/room/furnitures/eraser/eraser-uv.png Binary files differdeleted file mode 100644 index 89e4ea4c45..0000000000 --- a/src/client/assets/room/furnitures/eraser/eraser-uv.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/eraser/eraser.blend b/src/client/assets/room/furnitures/eraser/eraser.blend Binary files differdeleted file mode 100644 index 103c54fbae..0000000000 --- a/src/client/assets/room/furnitures/eraser/eraser.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/eraser/eraser.glb b/src/client/assets/room/furnitures/eraser/eraser.glb Binary files differdeleted file mode 100644 index 016b60df20..0000000000 --- a/src/client/assets/room/furnitures/eraser/eraser.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/facial-tissue/facial-tissue-uv.png b/src/client/assets/room/furnitures/facial-tissue/facial-tissue-uv.png Binary files differdeleted file mode 100644 index e3865ad15e..0000000000 --- a/src/client/assets/room/furnitures/facial-tissue/facial-tissue-uv.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/facial-tissue/facial-tissue.blend b/src/client/assets/room/furnitures/facial-tissue/facial-tissue.blend Binary files differdeleted file mode 100644 index d59f87c1ee..0000000000 --- a/src/client/assets/room/furnitures/facial-tissue/facial-tissue.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/facial-tissue/facial-tissue.glb b/src/client/assets/room/furnitures/facial-tissue/facial-tissue.glb Binary files differdeleted file mode 100644 index 48b36ef347..0000000000 --- a/src/client/assets/room/furnitures/facial-tissue/facial-tissue.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/facial-tissue/facial-tissue.png b/src/client/assets/room/furnitures/facial-tissue/facial-tissue.png Binary files differdeleted file mode 100644 index 7cee4b1859..0000000000 --- a/src/client/assets/room/furnitures/facial-tissue/facial-tissue.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/facial-tissue/facial-tissue.psd b/src/client/assets/room/furnitures/facial-tissue/facial-tissue.psd Binary files differdeleted file mode 100644 index cd59fc007b..0000000000 --- a/src/client/assets/room/furnitures/facial-tissue/facial-tissue.psd +++ /dev/null diff --git a/src/client/assets/room/furnitures/fan/fan.blend b/src/client/assets/room/furnitures/fan/fan.blend Binary files differdeleted file mode 100644 index 8c8106e5fe..0000000000 --- a/src/client/assets/room/furnitures/fan/fan.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/fan/fan.glb b/src/client/assets/room/furnitures/fan/fan.glb Binary files differdeleted file mode 100644 index d9367f3534..0000000000 --- a/src/client/assets/room/furnitures/fan/fan.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/holo-display/holo-display.blend b/src/client/assets/room/furnitures/holo-display/holo-display.blend Binary files differdeleted file mode 100644 index 56d2e1f819..0000000000 --- a/src/client/assets/room/furnitures/holo-display/holo-display.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/holo-display/holo-display.glb b/src/client/assets/room/furnitures/holo-display/holo-display.glb Binary files differdeleted file mode 100644 index 4d042a59b3..0000000000 --- a/src/client/assets/room/furnitures/holo-display/holo-display.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/holo-display/ray-uv.png b/src/client/assets/room/furnitures/holo-display/ray-uv.png Binary files differdeleted file mode 100644 index aa7e817e0f..0000000000 --- a/src/client/assets/room/furnitures/holo-display/ray-uv.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/holo-display/ray.png b/src/client/assets/room/furnitures/holo-display/ray.png Binary files differdeleted file mode 100644 index 6a5d24e143..0000000000 --- a/src/client/assets/room/furnitures/holo-display/ray.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/keyboard/keyboard.blend b/src/client/assets/room/furnitures/keyboard/keyboard.blend Binary files differdeleted file mode 100644 index ab33d134b3..0000000000 --- a/src/client/assets/room/furnitures/keyboard/keyboard.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/keyboard/keyboard.glb b/src/client/assets/room/furnitures/keyboard/keyboard.glb Binary files differdeleted file mode 100644 index 15dc69f47a..0000000000 --- a/src/client/assets/room/furnitures/keyboard/keyboard.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/low-table/low-table.blend b/src/client/assets/room/furnitures/low-table/low-table.blend Binary files differdeleted file mode 100644 index e1592174d9..0000000000 --- a/src/client/assets/room/furnitures/low-table/low-table.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/low-table/low-table.glb b/src/client/assets/room/furnitures/low-table/low-table.glb Binary files differdeleted file mode 100644 index c69bf35d7b..0000000000 --- a/src/client/assets/room/furnitures/low-table/low-table.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/mat/mat.blend b/src/client/assets/room/furnitures/mat/mat.blend Binary files differdeleted file mode 100644 index a1e1a68c55..0000000000 --- a/src/client/assets/room/furnitures/mat/mat.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/mat/mat.glb b/src/client/assets/room/furnitures/mat/mat.glb Binary files differdeleted file mode 100644 index 87ccd44e1a..0000000000 --- a/src/client/assets/room/furnitures/mat/mat.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/milk/milk-uv.png b/src/client/assets/room/furnitures/milk/milk-uv.png Binary files differdeleted file mode 100644 index 258fd54638..0000000000 --- a/src/client/assets/room/furnitures/milk/milk-uv.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/milk/milk.blend b/src/client/assets/room/furnitures/milk/milk.blend Binary files differdeleted file mode 100644 index 2df508d5b9..0000000000 --- a/src/client/assets/room/furnitures/milk/milk.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/milk/milk.glb b/src/client/assets/room/furnitures/milk/milk.glb Binary files differdeleted file mode 100644 index b335fe3d02..0000000000 --- a/src/client/assets/room/furnitures/milk/milk.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/milk/milk.png b/src/client/assets/room/furnitures/milk/milk.png Binary files differdeleted file mode 100644 index 35181c8c8c..0000000000 --- a/src/client/assets/room/furnitures/milk/milk.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/milk/milk.psd b/src/client/assets/room/furnitures/milk/milk.psd Binary files differdeleted file mode 100644 index f31e439277..0000000000 --- a/src/client/assets/room/furnitures/milk/milk.psd +++ /dev/null diff --git a/src/client/assets/room/furnitures/monitor/monitor.blend b/src/client/assets/room/furnitures/monitor/monitor.blend Binary files differdeleted file mode 100644 index 6c042ccdd8..0000000000 --- a/src/client/assets/room/furnitures/monitor/monitor.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/monitor/monitor.glb b/src/client/assets/room/furnitures/monitor/monitor.glb Binary files differdeleted file mode 100644 index fc33286a15..0000000000 --- a/src/client/assets/room/furnitures/monitor/monitor.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/monitor/monitor.psd b/src/client/assets/room/furnitures/monitor/monitor.psd Binary files differdeleted file mode 100644 index 57afff9cd9..0000000000 --- a/src/client/assets/room/furnitures/monitor/monitor.psd +++ /dev/null diff --git a/src/client/assets/room/furnitures/monitor/screen-uv.png b/src/client/assets/room/furnitures/monitor/screen-uv.png Binary files differdeleted file mode 100644 index 35f74de8aa..0000000000 --- a/src/client/assets/room/furnitures/monitor/screen-uv.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/monitor/screen.jpg b/src/client/assets/room/furnitures/monitor/screen.jpg Binary files differdeleted file mode 100644 index 4004a1ede9..0000000000 --- a/src/client/assets/room/furnitures/monitor/screen.jpg +++ /dev/null diff --git a/src/client/assets/room/furnitures/moon/moon.blend b/src/client/assets/room/furnitures/moon/moon.blend Binary files differdeleted file mode 100644 index 4ff3deab8e..0000000000 --- a/src/client/assets/room/furnitures/moon/moon.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/moon/moon.glb b/src/client/assets/room/furnitures/moon/moon.glb Binary files differdeleted file mode 100644 index 07fa7e4c02..0000000000 --- a/src/client/assets/room/furnitures/moon/moon.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/moon/moon.jpg b/src/client/assets/room/furnitures/moon/moon.jpg Binary files differdeleted file mode 100644 index 8988ac64b9..0000000000 --- a/src/client/assets/room/furnitures/moon/moon.jpg +++ /dev/null diff --git a/src/client/assets/room/furnitures/mousepad/mousepad.blend b/src/client/assets/room/furnitures/mousepad/mousepad.blend Binary files differdeleted file mode 100644 index 14bd139c94..0000000000 --- a/src/client/assets/room/furnitures/mousepad/mousepad.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/mousepad/mousepad.glb b/src/client/assets/room/furnitures/mousepad/mousepad.glb Binary files differdeleted file mode 100644 index 681ada49cd..0000000000 --- a/src/client/assets/room/furnitures/mousepad/mousepad.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/pc/motherboard-uv.png b/src/client/assets/room/furnitures/pc/motherboard-uv.png Binary files differdeleted file mode 100644 index 355009fe7c..0000000000 --- a/src/client/assets/room/furnitures/pc/motherboard-uv.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/pc/motherboard-uv.psd b/src/client/assets/room/furnitures/pc/motherboard-uv.psd Binary files differdeleted file mode 100644 index 971f33f79e..0000000000 --- a/src/client/assets/room/furnitures/pc/motherboard-uv.psd +++ /dev/null diff --git a/src/client/assets/room/furnitures/pc/motherboard.jpg b/src/client/assets/room/furnitures/pc/motherboard.jpg Binary files differdeleted file mode 100644 index d894e4efcf..0000000000 --- a/src/client/assets/room/furnitures/pc/motherboard.jpg +++ /dev/null diff --git a/src/client/assets/room/furnitures/pc/pc.blend b/src/client/assets/room/furnitures/pc/pc.blend Binary files differdeleted file mode 100644 index 13dfec6ccc..0000000000 --- a/src/client/assets/room/furnitures/pc/pc.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/pc/pc.glb b/src/client/assets/room/furnitures/pc/pc.glb Binary files differdeleted file mode 100644 index 44a48b18ae..0000000000 --- a/src/client/assets/room/furnitures/pc/pc.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/pencil/pencil.blend b/src/client/assets/room/furnitures/pencil/pencil.blend Binary files differdeleted file mode 100644 index 0fc6bdd776..0000000000 --- a/src/client/assets/room/furnitures/pencil/pencil.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/pencil/pencil.glb b/src/client/assets/room/furnitures/pencil/pencil.glb Binary files differdeleted file mode 100644 index a938b5cdcc..0000000000 --- a/src/client/assets/room/furnitures/pencil/pencil.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/photoframe/photo-uv.png b/src/client/assets/room/furnitures/photoframe/photo-uv.png Binary files differdeleted file mode 100644 index 9b94906413..0000000000 --- a/src/client/assets/room/furnitures/photoframe/photo-uv.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/photoframe/photo.jpg b/src/client/assets/room/furnitures/photoframe/photo.jpg Binary files differdeleted file mode 100644 index af14f0f36a..0000000000 --- a/src/client/assets/room/furnitures/photoframe/photo.jpg +++ /dev/null diff --git a/src/client/assets/room/furnitures/photoframe/photoframe.blend b/src/client/assets/room/furnitures/photoframe/photoframe.blend Binary files differdeleted file mode 100644 index 4224cde45b..0000000000 --- a/src/client/assets/room/furnitures/photoframe/photoframe.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/photoframe/photoframe.glb b/src/client/assets/room/furnitures/photoframe/photoframe.glb Binary files differdeleted file mode 100644 index 4255a77de6..0000000000 --- a/src/client/assets/room/furnitures/photoframe/photoframe.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/piano/piano.blend b/src/client/assets/room/furnitures/piano/piano.blend Binary files differdeleted file mode 100644 index 7653cdf672..0000000000 --- a/src/client/assets/room/furnitures/piano/piano.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/piano/piano.glb b/src/client/assets/room/furnitures/piano/piano.glb Binary files differdeleted file mode 100644 index 7242e78ceb..0000000000 --- a/src/client/assets/room/furnitures/piano/piano.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/pinguin/pinguin.blend b/src/client/assets/room/furnitures/pinguin/pinguin.blend Binary files differdeleted file mode 100644 index 514c713e4c..0000000000 --- a/src/client/assets/room/furnitures/pinguin/pinguin.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/pinguin/pinguin.glb b/src/client/assets/room/furnitures/pinguin/pinguin.glb Binary files differdeleted file mode 100644 index 6df34c06e9..0000000000 --- a/src/client/assets/room/furnitures/pinguin/pinguin.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/plant/plant-soil-uv.png b/src/client/assets/room/furnitures/plant/plant-soil-uv.png Binary files differdeleted file mode 100644 index d4971a896c..0000000000 --- a/src/client/assets/room/furnitures/plant/plant-soil-uv.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/plant/plant-soil.png b/src/client/assets/room/furnitures/plant/plant-soil.png Binary files differdeleted file mode 100644 index e79ccd240e..0000000000 --- a/src/client/assets/room/furnitures/plant/plant-soil.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/plant/plant-soil.psd b/src/client/assets/room/furnitures/plant/plant-soil.psd Binary files differdeleted file mode 100644 index 1457b7ea5b..0000000000 --- a/src/client/assets/room/furnitures/plant/plant-soil.psd +++ /dev/null diff --git a/src/client/assets/room/furnitures/plant/plant.blend b/src/client/assets/room/furnitures/plant/plant.blend Binary files differdeleted file mode 100644 index aa38c7b54e..0000000000 --- a/src/client/assets/room/furnitures/plant/plant.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/plant/plant.glb b/src/client/assets/room/furnitures/plant/plant.glb Binary files differdeleted file mode 100644 index 38422b4a9b..0000000000 --- a/src/client/assets/room/furnitures/plant/plant.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/plant2/plant2.blend b/src/client/assets/room/furnitures/plant2/plant2.blend Binary files differdeleted file mode 100644 index 6592c5d98d..0000000000 --- a/src/client/assets/room/furnitures/plant2/plant2.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/plant2/plant2.glb b/src/client/assets/room/furnitures/plant2/plant2.glb Binary files differdeleted file mode 100644 index 223e6f5834..0000000000 --- a/src/client/assets/room/furnitures/plant2/plant2.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/plant2/soil.png b/src/client/assets/room/furnitures/plant2/soil.png Binary files differdeleted file mode 100644 index e79ccd240e..0000000000 --- a/src/client/assets/room/furnitures/plant2/soil.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/poster-h/poster-h.blend b/src/client/assets/room/furnitures/poster-h/poster-h.blend Binary files differdeleted file mode 100644 index 40f944f3c1..0000000000 --- a/src/client/assets/room/furnitures/poster-h/poster-h.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/poster-h/poster-h.glb b/src/client/assets/room/furnitures/poster-h/poster-h.glb Binary files differdeleted file mode 100644 index c6032c1009..0000000000 --- a/src/client/assets/room/furnitures/poster-h/poster-h.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/poster-h/uv.png b/src/client/assets/room/furnitures/poster-h/uv.png Binary files differdeleted file mode 100644 index f854231e0b..0000000000 --- a/src/client/assets/room/furnitures/poster-h/uv.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/poster-v/poster-v.blend b/src/client/assets/room/furnitures/poster-v/poster-v.blend Binary files differdeleted file mode 100644 index 07fe971634..0000000000 --- a/src/client/assets/room/furnitures/poster-v/poster-v.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/poster-v/poster-v.glb b/src/client/assets/room/furnitures/poster-v/poster-v.glb Binary files differdeleted file mode 100644 index 6e3782f193..0000000000 --- a/src/client/assets/room/furnitures/poster-v/poster-v.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/poster-v/uv.png b/src/client/assets/room/furnitures/poster-v/uv.png Binary files differdeleted file mode 100644 index 7bb2bf809e..0000000000 --- a/src/client/assets/room/furnitures/poster-v/uv.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/pudding/pudding.blend b/src/client/assets/room/furnitures/pudding/pudding.blend Binary files differdeleted file mode 100644 index bba40ce161..0000000000 --- a/src/client/assets/room/furnitures/pudding/pudding.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/pudding/pudding.glb b/src/client/assets/room/furnitures/pudding/pudding.glb Binary files differdeleted file mode 100644 index 06c9ed80cc..0000000000 --- a/src/client/assets/room/furnitures/pudding/pudding.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/rubik-cube/rubik-cube.blend b/src/client/assets/room/furnitures/rubik-cube/rubik-cube.blend Binary files differdeleted file mode 100644 index 6c09067e78..0000000000 --- a/src/client/assets/room/furnitures/rubik-cube/rubik-cube.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/rubik-cube/rubik-cube.glb b/src/client/assets/room/furnitures/rubik-cube/rubik-cube.glb Binary files differdeleted file mode 100644 index d640df9b06..0000000000 --- a/src/client/assets/room/furnitures/rubik-cube/rubik-cube.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/server/rack-uv.png b/src/client/assets/room/furnitures/server/rack-uv.png Binary files differdeleted file mode 100644 index 65bdb0ffd9..0000000000 --- a/src/client/assets/room/furnitures/server/rack-uv.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/server/rack.png b/src/client/assets/room/furnitures/server/rack.png Binary files differdeleted file mode 100644 index b851295cfa..0000000000 --- a/src/client/assets/room/furnitures/server/rack.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/server/server.blend b/src/client/assets/room/furnitures/server/server.blend Binary files differdeleted file mode 100644 index 6675dfbdc2..0000000000 --- a/src/client/assets/room/furnitures/server/server.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/server/server.glb b/src/client/assets/room/furnitures/server/server.glb Binary files differdeleted file mode 100644 index a8b530a2d2..0000000000 --- a/src/client/assets/room/furnitures/server/server.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/server/server.png b/src/client/assets/room/furnitures/server/server.png Binary files differdeleted file mode 100644 index 8e9a0d716c..0000000000 --- a/src/client/assets/room/furnitures/server/server.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/server/uv.png b/src/client/assets/room/furnitures/server/uv.png Binary files differdeleted file mode 100644 index ca2e747d16..0000000000 --- a/src/client/assets/room/furnitures/server/uv.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/sofa/sofa.blend b/src/client/assets/room/furnitures/sofa/sofa.blend Binary files differdeleted file mode 100644 index fb5aa51a2c..0000000000 --- a/src/client/assets/room/furnitures/sofa/sofa.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/sofa/sofa.glb b/src/client/assets/room/furnitures/sofa/sofa.glb Binary files differdeleted file mode 100644 index 6ce77d94ac..0000000000 --- a/src/client/assets/room/furnitures/sofa/sofa.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/spiral/spiral.blend b/src/client/assets/room/furnitures/spiral/spiral.blend Binary files differdeleted file mode 100644 index 9d3be77bce..0000000000 --- a/src/client/assets/room/furnitures/spiral/spiral.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/spiral/spiral.glb b/src/client/assets/room/furnitures/spiral/spiral.glb Binary files differdeleted file mode 100644 index ee8e3c23b1..0000000000 --- a/src/client/assets/room/furnitures/spiral/spiral.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/tv/screen-uv.png b/src/client/assets/room/furnitures/tv/screen-uv.png Binary files differdeleted file mode 100644 index 4bb74f031f..0000000000 --- a/src/client/assets/room/furnitures/tv/screen-uv.png +++ /dev/null diff --git a/src/client/assets/room/furnitures/tv/tv.blend b/src/client/assets/room/furnitures/tv/tv.blend Binary files differdeleted file mode 100644 index 490e298e7b..0000000000 --- a/src/client/assets/room/furnitures/tv/tv.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/tv/tv.glb b/src/client/assets/room/furnitures/tv/tv.glb Binary files differdeleted file mode 100644 index b9bd23896b..0000000000 --- a/src/client/assets/room/furnitures/tv/tv.glb +++ /dev/null diff --git a/src/client/assets/room/furnitures/wall-clock/wall-clock.blend b/src/client/assets/room/furnitures/wall-clock/wall-clock.blend Binary files differdeleted file mode 100644 index 0a61c8f01e..0000000000 --- a/src/client/assets/room/furnitures/wall-clock/wall-clock.blend +++ /dev/null diff --git a/src/client/assets/room/furnitures/wall-clock/wall-clock.glb b/src/client/assets/room/furnitures/wall-clock/wall-clock.glb Binary files differdeleted file mode 100644 index b9f0093a8d..0000000000 --- a/src/client/assets/room/furnitures/wall-clock/wall-clock.glb +++ /dev/null diff --git a/src/client/assets/room/rooms/default/default.blend b/src/client/assets/room/rooms/default/default.blend Binary files differdeleted file mode 100644 index 661154724a..0000000000 --- a/src/client/assets/room/rooms/default/default.blend +++ /dev/null diff --git a/src/client/assets/room/rooms/default/default.glb b/src/client/assets/room/rooms/default/default.glb Binary files differdeleted file mode 100644 index 3d378deee2..0000000000 --- a/src/client/assets/room/rooms/default/default.glb +++ /dev/null diff --git a/src/client/assets/room/rooms/washitsu/husuma-uv.png b/src/client/assets/room/rooms/washitsu/husuma-uv.png Binary files differdeleted file mode 100644 index ae2fca3911..0000000000 --- a/src/client/assets/room/rooms/washitsu/husuma-uv.png +++ /dev/null diff --git a/src/client/assets/room/rooms/washitsu/husuma.png b/src/client/assets/room/rooms/washitsu/husuma.png Binary files differdeleted file mode 100644 index 084cbed67c..0000000000 --- a/src/client/assets/room/rooms/washitsu/husuma.png +++ /dev/null diff --git a/src/client/assets/room/rooms/washitsu/tatami-single1600.png b/src/client/assets/room/rooms/washitsu/tatami-single1600.png Binary files differdeleted file mode 100644 index c0e684d743..0000000000 --- a/src/client/assets/room/rooms/washitsu/tatami-single1600.png +++ /dev/null diff --git a/src/client/assets/room/rooms/washitsu/tatami-uv.png b/src/client/assets/room/rooms/washitsu/tatami-uv.png Binary files differdeleted file mode 100644 index 5b16c66091..0000000000 --- a/src/client/assets/room/rooms/washitsu/tatami-uv.png +++ /dev/null diff --git a/src/client/assets/room/rooms/washitsu/tatami.afdesign b/src/client/assets/room/rooms/washitsu/tatami.afdesign Binary files differdeleted file mode 100644 index 9300a26950..0000000000 --- a/src/client/assets/room/rooms/washitsu/tatami.afdesign +++ /dev/null diff --git a/src/client/assets/room/rooms/washitsu/tatami.png b/src/client/assets/room/rooms/washitsu/tatami.png Binary files differdeleted file mode 100644 index 8894d040ae..0000000000 --- a/src/client/assets/room/rooms/washitsu/tatami.png +++ /dev/null diff --git a/src/client/assets/room/rooms/washitsu/washitsu.blend b/src/client/assets/room/rooms/washitsu/washitsu.blend Binary files differdeleted file mode 100644 index 84dc11374d..0000000000 --- a/src/client/assets/room/rooms/washitsu/washitsu.blend +++ /dev/null diff --git a/src/client/assets/room/rooms/washitsu/washitsu.glb b/src/client/assets/room/rooms/washitsu/washitsu.glb Binary files differdeleted file mode 100644 index 5b4767bc73..0000000000 --- a/src/client/assets/room/rooms/washitsu/washitsu.glb +++ /dev/null diff --git a/src/client/assets/thumbnail-not-available.png b/src/client/assets/thumbnail-not-available.png Binary files differdeleted file mode 100644 index 07cad9919c..0000000000 --- a/src/client/assets/thumbnail-not-available.png +++ /dev/null diff --git a/src/client/assets/title.svg b/src/client/assets/title.svg deleted file mode 100644 index 0e4e0b8b3b..0000000000 --- a/src/client/assets/title.svg +++ /dev/null @@ -1,140 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<!-- Created with Inkscape (http://www.inkscape.org/) --> - -<svg - xmlns:dc="http://purl.org/dc/elements/1.1/" - xmlns:cc="http://creativecommons.org/ns#" - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" - xmlns:svg="http://www.w3.org/2000/svg" - xmlns="http://www.w3.org/2000/svg" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="614.71039" - height="205.08009" - viewBox="0 0 162.64213 54.260776" - version="1.1" - id="svg8" - inkscape:version="0.92.1 r15371" - sodipodi:docname="misskey.svg" - inkscape:export-filename="C:\Users\Takumiya_Cho\Desktop\misskey.png" - inkscape:export-xdpi="96" - inkscape:export-ydpi="96"> - <defs - id="defs2"> - <inkscape:path-effect - effect="simplify" - id="path-effect5115" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - <inkscape:path-effect - effect="simplify" - id="path-effect5104" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - </defs> - <sodipodi:namedview - id="base" - pagecolor="#ffffff" - bordercolor="#666666" - borderopacity="1.0" - inkscape:pageopacity="0.0" - inkscape:pageshadow="2" - inkscape:zoom="0.9899495" - inkscape:cx="370.82839" - inkscape:cy="79.043895" - inkscape:document-units="mm" - inkscape:current-layer="layer1" - showgrid="false" - units="px" - inkscape:snap-bbox="true" - inkscape:bbox-nodes="true" - inkscape:snap-bbox-edge-midpoints="false" - inkscape:snap-smooth-nodes="true" - inkscape:snap-center="true" - inkscape:snap-page="true" - inkscape:window-width="1920" - inkscape:window-height="1017" - inkscape:window-x="-8" - inkscape:window-y="1072" - inkscape:window-maximized="1" - inkscape:object-paths="true" - inkscape:bbox-paths="true" - fit-margin-top="50" - fit-margin-left="50" - fit-margin-bottom="20" - fit-margin-right="50" /> - <metadata - id="metadata5"> - <rdf:RDF> - <cc:Work - rdf:about=""> - <dc:format>image/svg+xml</dc:format> - <dc:type - rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> - <dc:title></dc:title> - </cc:Work> - </rdf:RDF> - </metadata> - <g - inkscape:label="レイヤー 1" - inkscape:groupmode="layer" - id="layer1" - transform="translate(-11.097531,-173.29664)"> - <g - transform="matrix(0.28612302,0,0,0.28612302,17.176981,141.74334)" - id="text4489-6" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.92471898px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - aria-label="Mi"> - <path - sodipodi:nodetypes="zccssscssccscczzzccsccsscscsccz" - inkscape:connector-curvature="0" - id="path5210" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill-opacity:1;stroke-width:0.92471898px" - d="m 75.196381,231.17126 c -5.855419,0.0202 -10.885068,-3.50766 -13.2572,-7.61584 -1.266603,-1.79454 -3.772419,-2.43291 -3.807919,0 v 11.2332 c 0,4.51309 -1.645397,8.41504 -4.936191,11.70583 -3.196772,3.19677 -7.098714,4.79516 -11.705826,4.79516 -4.513089,0 -8.415031,-1.59839 -11.705825,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -61.7729 c 0,-3.47884 0.987238,-6.6286 2.961715,-9.44928 2.068499,-2.91471 4.701135,-4.9362 7.897906,-6.06447 1.786431,-0.65816 3.666885,-0.98724 5.641362,-0.98724 5.077225,0 9.308247,1.97448 12.693064,5.92343 1.786431,1.97448 2.820681,3.00873 3.102749,3.10275 0,0 13.408119,16.21319 13.78421,16.49526 0.376091,0.28206 1.480789,2.43848 4.127113,2.43848 2.646324,0 3.89218,-2.15642 4.26827,-2.43848 0.376091,-0.28207 13.784088,-16.49526 13.784088,-16.49526 0.09402,0.094 1.081261,-0.94022 2.961715,-3.10275 3.478837,-3.94895 7.756866,-5.92343 12.834096,-5.92343 1.88045,0 3.76091,0.32908 5.64136,0.98724 3.19677,1.12827 5.7824,3.14976 7.75688,6.06447 2.06849,2.82068 3.10274,5.97044 3.10274,9.44928 v 61.7729 c 0,4.51309 -1.6454,8.41504 -4.93619,11.70583 -3.19677,3.19677 -7.09871,4.79516 -11.70582,4.79516 -4.51309,0 -8.41504,-1.59839 -11.705828,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -11.2332 c -0.277898,-3.06563 -2.987588,-1.13379 -3.948953,0 -2.538613,4.70114 -7.401781,7.59567 -13.2572,7.61584 z" /> - <path - inkscape:connector-curvature="0" - id="path5212" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill-opacity:1;stroke-width:0.92471898px" - d="m 145.83461,185.00361 q -5.92343,0 -10.15445,-4.08999 -4.08999,-4.23102 -4.08999,-10.15445 0,-5.92343 4.08999,-10.01342 4.23102,-4.23102 10.15445,-4.23102 5.92343,0 10.15445,4.23102 4.23102,4.08999 4.23102,10.01342 0,5.92343 -4.23102,10.15445 -4.23102,4.08999 -10.15445,4.08999 z m 0.14103,2.82068 q 5.92343,0 10.01342,4.23102 4.23102,4.23102 4.23102,10.15445 v 34.83541 q 0,5.92343 -4.23102,10.15445 -4.08999,4.08999 -10.01342,4.08999 -5.92343,0 -10.15445,-4.08999 -4.23102,-4.23102 -4.23102,-10.15445 v -34.83541 q 0,-5.92343 4.23102,-10.15445 4.23102,-4.23102 10.15445,-4.23102 z" /> - </g> - <path - inkscape:connector-curvature="0" - id="path5199" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 72.022691,200.53715 q 0.968125,0.24203 2.420312,0.5244 2.420313,0.40339 3.791824,1.29083 2.581666,1.69422 2.581666,5.08266 0,2.74302 -1.815234,4.47758 -2.097604,2.01693 -5.849089,2.01693 -2.743021,0 -6.131458,-0.76644 -1.089141,-0.24203 -1.774896,-1.08914 -0.645417,-0.84711 -0.645417,-1.89591 0,-1.29083 0.887448,-2.17828 0.927786,-0.92779 2.178281,-0.92779 0.363047,0 0.685756,0.0807 1.169817,0.24203 4.477578,0.60508 0.443724,0 0.968125,-0.0403 0.201693,0 0.201693,-0.24203 0.04034,-0.20169 -0.242032,-0.28237 -1.37151,-0.24203 -2.541328,-0.5244 -1.331172,-0.28237 -1.895911,-0.48406 -1.12948,-0.32271 -1.895912,-0.84711 -2.581667,-1.69422 -2.622005,-5.08266 0,-2.70268 1.855573,-4.47758 2.258958,-2.17828 6.413828,-1.97659 2.783359,0.12102 5.566719,0.7261 1.048802,0.24203 1.734557,1.08914 0.685756,0.84711 0.685756,1.93625 0,1.25049 -0.927787,2.17828 -0.887448,0.88745 -2.178281,0.88745 -0.322709,0 -0.645417,-0.0807 -1.169818,-0.24203 -4.517917,-0.56474 -0.403385,-0.0403 -0.766432,0 -0.322708,0.0403 -0.322708,0.24203 0.04034,0.24203 0.322708,0.32271 z" /> - <path - inkscape:connector-curvature="0" - id="path5201" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 89.577027,200.53715 q 0.968125,0.24203 2.420312,0.5244 2.420313,0.40339 3.791823,1.29083 2.581667,1.69422 2.581667,5.08266 0,2.74302 -1.815234,4.47758 -2.097604,2.01693 -5.849089,2.01693 -2.743021,0 -6.131458,-0.76644 -1.089141,-0.24203 -1.774896,-1.08914 -0.645417,-0.84711 -0.645417,-1.89591 0,-1.29083 0.887448,-2.17828 0.927786,-0.92779 2.178281,-0.92779 0.363047,0 0.685755,0.0807 1.169818,0.24203 4.477579,0.60508 0.443724,0 0.968125,-0.0403 0.201692,0 0.201692,-0.24203 0.04034,-0.20169 -0.242031,-0.28237 -1.37151,-0.24203 -2.541328,-0.5244 -1.331172,-0.28237 -1.895912,-0.48406 -1.129479,-0.32271 -1.895911,-0.84711 -2.581667,-1.69422 -2.622005,-5.08266 0,-2.70268 1.855573,-4.47758 2.258958,-2.17828 6.413828,-1.97659 2.783359,0.12102 5.566719,0.7261 1.048802,0.24203 1.734557,1.08914 0.685755,0.84711 0.685755,1.93625 0,1.25049 -0.927786,2.17828 -0.887448,0.88745 -2.178281,0.88745 -0.322709,0 -0.645417,-0.0807 -1.169818,-0.24203 -4.517917,-0.56474 -0.403385,-0.0403 -0.766432,0 -0.322708,0.0403 -0.322708,0.24203 0.04034,0.24203 0.322708,0.32271 z" /> - <path - inkscape:connector-curvature="0" - id="path5203" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 115.65209,203.87137 q 0.12101,0.0807 2.86404,2.78336 1.25049,1.21016 1.25049,2.94471 0,1.61354 -1.16982,2.86404 -1.16982,1.21016 -2.90437,1.21016 -1.65388,0 -2.86404,-1.16982 l -4.03385,-3.91284 q -0.16136,-0.12102 -0.32271,-0.12102 -0.32271,0 -0.32271,1.21016 0,1.69422 -1.21016,2.90438 -1.21015,1.16981 -2.90437,1.16981 -1.69422,0 -2.90438,-1.16981 -1.169807,-1.21016 -1.169807,-2.90438 v -18.79776 q 0,-1.69422 1.169807,-2.86404 1.21016,-1.21015 2.90438,-1.21015 1.69422,0 2.90437,1.21015 1.21016,1.16982 1.21016,2.86404 v 6.29281 q 0,0.40339 0.28237,0.5244 0.24203,0.12102 0.5244,-0.0807 0.16135,-0.0807 4.84063,-3.18675 1.0488,-0.64542 2.25895,-0.64542 2.21862,0 3.42878,1.81524 0.64542,1.0488 0.64542,2.25896 0,2.21862 -1.81524,3.42877 l -2.54133,1.61354 v 0.0403 l -0.0807,0.0403 q -0.56474,0.36305 -0.0403,0.88745 z" /> - <path - inkscape:connector-curvature="0" - id="path5205" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 131.25181,213.92955 q -4.19521,0 -7.18026,-2.94472 -2.94472,-2.98505 -2.94472,-7.18026 0,-4.15487 2.94472,-7.09958 2.98505,-2.98505 7.18026,-2.98505 4.15487,0 6.97857,2.78335 0.92778,0.92779 0.92778,2.25896 0,1.33118 -0.92778,2.25896 l -4.67928,4.63893 q -1.00846,1.00847 -2.01692,1.00847 -1.45219,0 -2.25896,-0.80677 -0.80677,-0.80677 -0.80677,-2.13795 0,-1.29083 0.92778,-2.21862 l 0.80678,-0.84711 q 0.16135,-0.12101 0.0807,-0.24203 -0.12101,-0.0807 -0.32271,-0.0403 -0.80677,0.20169 -1.37151,0.80677 -1.12948,1.08914 -1.12948,2.622 0,1.5732 1.08915,2.70268 1.12947,1.08914 2.70268,1.08914 1.53286,0 2.622,-1.12947 0.92779,-0.92779 2.25896,-0.92779 1.33117,0 2.25896,0.92779 0.92779,0.92778 0.92779,2.25895 0,1.33118 -0.92779,2.25896 -2.98505,2.94472 -7.13992,2.94472 z" /> - <path - inkscape:connector-curvature="0" - id="path5207" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 160.51049,198.1433 v 5.60705 q 0,0.56474 -0.0807,1.21016 v 7.38195 q 0,4.51792 -2.74302,7.2206 -2.70268,2.70269 -7.30128,2.70269 -2.66234,0 -4.80028,-1.00847 -2.13795,-0.96812 -2.13795,-3.3481 0,-0.80677 0.36305,-1.53286 0.96812,-2.17828 3.3481,-2.17828 0.56474,0 1.5732,0.32271 1.00847,0.3227 1.65388,0.3227 1.69422,0 2.21862,-0.72609 0.20169,-0.28237 0.0807,-0.44372 -0.16136,-0.24204 -0.56474,-0.16136 -0.68576,0.12102 -1.49253,0.12102 -4.07419,0 -6.97856,-2.90438 -2.90438,-2.90437 -2.90438,-6.97857 v -5.60705 q 0,-1.69422 1.16982,-2.86404 1.21015,-1.21016 2.90437,-1.21016 1.69422,0 2.90438,1.21016 1.21015,1.16982 1.21015,2.86404 v 5.60705 q 0,0.68576 0.48407,1.21016 0.5244,0.48406 1.21015,0.48406 0.7261,0 1.21016,-0.48406 0.48406,-0.5244 0.48406,-1.21016 v -5.60705 q 0,-1.69422 1.21016,-2.86404 1.21015,-1.21016 2.90437,-1.21016 1.69422,0 2.86404,1.21016 1.21016,1.16982 1.21016,2.86404 z" /> - </g> -</svg> diff --git a/src/client/assets/unread.svg b/src/client/assets/unread.svg deleted file mode 100644 index 8c3cc9f475..0000000000 --- a/src/client/assets/unread.svg +++ /dev/null @@ -1,7 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?>
-<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" id="レイヤー_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
- y="0px" width="32px" height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve">
-<circle fill="#3AA2DC" cx="16.5" cy="16.5" r="6"/>
-</svg>
diff --git a/src/client/assets/version.html b/src/client/assets/version.html deleted file mode 100644 index 177d37db8f..0000000000 --- a/src/client/assets/version.html +++ /dev/null @@ -1,18 +0,0 @@ -<!DOCTYPE html> - -<html> - <head> - <meta charset="utf-8"> - <title>Misskeyのリカバリ</title> - <script> - const v = window.prompt('Enter version:'); - if (v) { - localStorage.setItem('v', v); - } - - setTimeout(() => { - location.href = '/'; - }, 500); - </script> - </head> -</html> diff --git a/src/client/assets/welcome-bg.dark.svg b/src/client/assets/welcome-bg.dark.svg deleted file mode 100644 index 1866170327..0000000000 --- a/src/client/assets/welcome-bg.dark.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900"><path d="M667,476L630,367L567,460Z" fill="#121931" stroke="#121931" stroke-width="1.51"/><path d="M630,367L576,311L567,460Z" fill="#121c31" stroke="#121c31" stroke-width="1.51"/><path d="M567,460L631,579L667,476Z" fill="#121731" stroke="#121731" stroke-width="1.51"/><path d="M567,460L556,584L631,579Z" fill="#131830" stroke="#131830" stroke-width="1.51"/><path d="M437,475L556,584L567,460Z" fill="#121a31" stroke="#121a31" stroke-width="1.51"/><path d="M667,476L773,349L630,367Z" fill="#121831" stroke="#121831" stroke-width="1.51"/><path d="M630,367L580,255L576,311Z" fill="#122031" stroke="#122031" stroke-width="1.51"/><path d="M813,438L773,349L667,476Z" fill="#121530" stroke="#121530" stroke-width="1.51"/><path d="M576,311L437,475L567,460Z" fill="#111d31" stroke="#111d31" stroke-width="1.51"/><path d="M642,193L580,255L630,367Z" fill="#112131" stroke="#112131" stroke-width="1.51"/><path d="M800,574L813,438L667,476Z" fill="#121330" stroke="#121330" stroke-width="1.51"/><path d="M400,299L437,475L576,311Z" fill="#122231" stroke="#122231" stroke-width="1.51"/><path d="M556,584L583,647L631,579Z" fill="#14162f" stroke="#14162f" stroke-width="1.51"/><path d="M631,579L800,574L667,476Z" fill="#121430" stroke="#121430" stroke-width="1.51"/><path d="M423,693L583,647L556,584Z" fill="#14192f" stroke="#14192f" stroke-width="1.51"/><path d="M653,680L800,574L631,579Z" fill="#13132f" stroke="#13132f" stroke-width="1.51"/><path d="M773,349L642,193L630,367Z" fill="#111d31" stroke="#111d31" stroke-width="1.51"/><path d="M813,438L874,423L773,349Z" fill="#131630" stroke="#131630" stroke-width="1.51"/><path d="M773,349L789,203L642,193Z" fill="#121f31" stroke="#121f31" stroke-width="1.51"/><path d="M906,568L874,423L813,438Z" fill="#13122f" stroke="#13122f" stroke-width="1.51"/><path d="M583,647L653,680L631,579Z" fill="#14152e" stroke="#14152e" stroke-width="1.51"/><path d="M449,219L400,299L576,311Z" fill="#112631" stroke="#112631" stroke-width="1.51"/><path d="M449,219L576,311L580,255Z" fill="#112431" stroke="#112431" stroke-width="1.51"/><path d="M437,475L411,599L556,584Z" fill="#131c30" stroke="#131c30" stroke-width="1.51"/><path d="M556,765L666,768L653,680Z" fill="#15142d" stroke="#15142d" stroke-width="1.51"/><path d="M327,557L411,599L437,475Z" fill="#131f30" stroke="#131f30" stroke-width="1.51"/><path d="M572,116L449,219L580,255Z" fill="#102731" stroke="#102731" stroke-width="1.51"/><path d="M920,229L789,203L773,349Z" fill="#131d30" stroke="#131d30" stroke-width="1.51"/><path d="M874,423L909,355L773,349Z" fill="#131730" stroke="#131730" stroke-width="1.51"/><path d="M1018,299L909,355L1040,413Z" fill="#14172e" stroke="#14172e" stroke-width="1.51"/><path d="M642,193L572,116L580,255Z" fill="#112631" stroke="#112631" stroke-width="1.51"/><path d="M802,140L639,121L642,193Z" fill="#112531" stroke="#112531" stroke-width="1.51"/><path d="M800,574L906,568L813,438Z" fill="#13122f" stroke="#13122f" stroke-width="1.51"/><path d="M860,653L906,568L800,574Z" fill="#14112e" stroke="#14112e" stroke-width="1.51"/><path d="M653,680L792,696L800,574Z" fill="#14112e" stroke="#14112e" stroke-width="1.51"/><path d="M639,121L572,116L642,193Z" fill="#102731" stroke="#102731" stroke-width="1.51"/><path d="M280,464L327,557L437,475Z" fill="#122131" stroke="#122131" stroke-width="1.51"/><path d="M792,696L860,653L800,574Z" fill="#15102d" stroke="#15102d" stroke-width="1.51"/><path d="M315,686L423,693L411,599Z" fill="#141d2f" stroke="#141d2f" stroke-width="1.51"/><path d="M411,599L423,693L556,584Z" fill="#141b2f" stroke="#141b2f" stroke-width="1.51"/><path d="M653,680L666,768L792,696Z" fill="#14112d" stroke="#14112d" stroke-width="1.51"/><path d="M400,299L298,362L437,475Z" fill="#112431" stroke="#112431" stroke-width="1.51"/><path d="M289,189L298,362L400,299Z" fill="#0f2a31" stroke="#0f2a31" stroke-width="1.51"/><path d="M556,765L653,680L583,647Z" fill="#15152e" stroke="#15152e" stroke-width="1.51"/><path d="M789,203L802,140L642,193Z" fill="#112231" stroke="#112231" stroke-width="1.51"/><path d="M920,229L802,140L789,203Z" fill="#132130" stroke="#132130" stroke-width="1.51"/><path d="M423,693L556,765L583,647Z" fill="#15182e" stroke="#15182e" stroke-width="1.51"/><path d="M298,362L280,464L437,475Z" fill="#112331" stroke="#112331" stroke-width="1.51"/><path d="M909,355L920,229L773,349Z" fill="#131a30" stroke="#131a30" stroke-width="1.51"/><path d="M1018,299L920,229L909,355Z" fill="#141a2f" stroke="#141a2f" stroke-width="1.51"/><path d="M675,877L748,809L666,768Z" fill="#17112d" stroke="#17112d" stroke-width="1.51"/><path d="M423,693L443,781L556,765Z" fill="#16192e" stroke="#16192e" stroke-width="1.51"/><path d="M336,786L443,781L423,693Z" fill="#161c2d" stroke="#161c2d" stroke-width="1.51"/><path d="M792,696L924,797L860,653Z" fill="#150e2c" stroke="#150e2c" stroke-width="1.51"/><path d="M666,768L748,809L792,696Z" fill="#150f2d" stroke="#150f2d" stroke-width="1.51"/><path d="M675,877L666,768L556,765Z" fill="#17132d" stroke="#17132d" stroke-width="1.51"/><path d="M327,557L315,686L411,599Z" fill="#141f2f" stroke="#141f2f" stroke-width="1.51"/><path d="M224,597L315,686L327,557Z" fill="#14222f" stroke="#14222f" stroke-width="1.51"/><path d="M566,23L421,77L572,116Z" fill="#132a2e" stroke="#132a2e" stroke-width="1.51"/><path d="M572,116L421,77L449,219Z" fill="#0e2d31" stroke="#0e2d31" stroke-width="1.51"/><path d="M449,219L289,189L400,299Z" fill="#0f2b31" stroke="#0f2b31" stroke-width="1.51"/><path d="M566,23L572,116L639,121Z" fill="#13282f" stroke="#13282f" stroke-width="1.51"/><path d="M644,-20L566,23L639,121Z" fill="#16272d" stroke="#16272d" stroke-width="1.51"/><path d="M328,133L289,189L449,219Z" fill="#0d2f31" stroke="#0d2f31" stroke-width="1.51"/><path d="M171,486L224,597L280,464Z" fill="#13232f" stroke="#13232f" stroke-width="1.51"/><path d="M1040,413L909,355L874,423Z" fill="#14152f" stroke="#14152f" stroke-width="1.51"/><path d="M920,229L902,89L802,140Z" fill="#132230" stroke="#132230" stroke-width="1.51"/><path d="M1020,585L906,568L1018,660Z" fill="#150f2c" stroke="#150f2c" stroke-width="1.51"/><path d="M1020,585L1040,413L906,568Z" fill="#14102d" stroke="#14102d" stroke-width="1.51"/><path d="M906,568L1040,413L874,423Z" fill="#13122f" stroke="#13122f" stroke-width="1.51"/><path d="M421,77L328,133L449,219Z" fill="#0d2f31" stroke="#0d2f31" stroke-width="1.51"/><path d="M1018,299L987,217L920,229Z" fill="#141d2f" stroke="#141d2f" stroke-width="1.51"/><path d="M755,-6L644,-20L639,121Z" fill="#16242d" stroke="#16242d" stroke-width="1.51"/><path d="M1018,660L906,568L860,653Z" fill="#15102c" stroke="#15102c" stroke-width="1.51"/><path d="M190,365L280,464L298,362Z" fill="#102731" stroke="#102731" stroke-width="1.51"/><path d="M280,464L224,597L327,557Z" fill="#122330" stroke="#122330" stroke-width="1.51"/><path d="M225,235L190,365L298,362Z" fill="#102b31" stroke="#102b31" stroke-width="1.51"/><path d="M1009,122L902,89L920,229Z" fill="#14222f" stroke="#14222f" stroke-width="1.51"/><path d="M289,189L225,235L298,362Z" fill="#0d2e31" stroke="#0d2e31" stroke-width="1.51"/><path d="M185,70L225,235L289,189Z" fill="#0c3330" stroke="#0c3330" stroke-width="1.51"/><path d="M527,877L675,877L556,765Z" fill="#19152d" stroke="#19152d" stroke-width="1.51"/><path d="M976,767L1018,660L860,653Z" fill="#160e2c" stroke="#160e2c" stroke-width="1.51"/><path d="M566,23L439,-17L421,77Z" fill="#152c2c" stroke="#152c2c" stroke-width="1.51"/><path d="M755,-6L639,121L802,140Z" fill="#14242f" stroke="#14242f" stroke-width="1.51"/><path d="M190,365L171,486L280,464Z" fill="#132430" stroke="#132430" stroke-width="1.51"/><path d="M902,89L755,-6L802,140Z" fill="#15232e" stroke="#15232e" stroke-width="1.51"/><path d="M924,797L792,696L748,809Z" fill="#160e2c" stroke="#160e2c" stroke-width="1.51"/><path d="M1020,585L1106,477L1040,413Z" fill="#14102d" stroke="#14102d" stroke-width="1.51"/><path d="M443,781L527,877L556,765Z" fill="#17182d" stroke="#17182d" stroke-width="1.51"/><path d="M427,924L527,877L443,781Z" fill="#1b192d" stroke="#1b192d" stroke-width="1.51"/><path d="M315,686L336,786L423,693Z" fill="#151e2e" stroke="#151e2e" stroke-width="1.51"/><path d="M201,784L336,786L315,686Z" fill="#16202d" stroke="#16202d" stroke-width="1.51"/><path d="M779,911L924,797L748,809Z" fill="#190e2c" stroke="#190e2c" stroke-width="1.51"/><path d="M421,77L296,-6L328,133Z" fill="#10312e" stroke="#10312e" stroke-width="1.51"/><path d="M328,133L185,70L289,189Z" fill="#093431" stroke="#093431" stroke-width="1.51"/><path d="M545,-110L439,-17L566,23Z" fill="#18292a" stroke="#18292a" stroke-width="1.51"/><path d="M1098,562L1106,477L1020,585Z" fill="#150f2d" stroke="#150f2d" stroke-width="1.51"/><path d="M1040,413L1149,365L1018,299Z" fill="#15162e" stroke="#15162e" stroke-width="1.51"/><path d="M1135,188L1009,122L987,217Z" fill="#14202e" stroke="#14202e" stroke-width="1.51"/><path d="M224,597L189,651L315,686Z" fill="#15222e" stroke="#15222e" stroke-width="1.51"/><path d="M122,584L189,651L224,597Z" fill="#16212d" stroke="#16212d" stroke-width="1.51"/><path d="M1095,646L1098,562L1020,585Z" fill="#160e2c" stroke="#160e2c" stroke-width="1.51"/><path d="M924,797L976,767L860,653Z" fill="#160d2b" stroke="#160d2b" stroke-width="1.51"/><path d="M1095,646L1020,585L1018,660Z" fill="#160e2b" stroke="#160e2b" stroke-width="1.51"/><path d="M902,89L855,-47L755,-6Z" fill="#18222b" stroke="#18222b" stroke-width="1.51"/><path d="M987,217L1009,122L920,229Z" fill="#14202f" stroke="#14202f" stroke-width="1.51"/><path d="M1135,188L987,217L1018,299Z" fill="#141c2e" stroke="#141c2e" stroke-width="1.51"/><path d="M675,877L779,911L748,809Z" fill="#1a102d" stroke="#1a102d" stroke-width="1.51"/><path d="M924,797L1032,900L976,767Z" fill="#190d2a" stroke="#190d2a" stroke-width="1.51"/><path d="M758,1032L779,911L675,877Z" fill="#1d102d" stroke="#1d102d" stroke-width="1.51"/><path d="M1153,782L1095,646L1018,660Z" fill="#170c2b" stroke="#170c2b" stroke-width="1.51"/><path d="M1262,481L1149,365L1106,477Z" fill="#15102d" stroke="#15102d" stroke-width="1.51"/><path d="M171,486L122,584L224,597Z" fill="#16222d" stroke="#16222d" stroke-width="1.51"/><path d="M70,425L122,584L171,486Z" fill="#17232c" stroke="#17232c" stroke-width="1.51"/><path d="M70,425L171,486L190,365Z" fill="#15242e" stroke="#15242e" stroke-width="1.51"/><path d="M527,877L566,1030L675,877Z" fill="#1d152d" stroke="#1d152d" stroke-width="1.51"/><path d="M336,786L350,882L443,781Z" fill="#181d2d" stroke="#181d2d" stroke-width="1.51"/><path d="M201,784L350,882L336,786Z" fill="#18202d" stroke="#18202d" stroke-width="1.51"/><path d="M201,784L315,686L189,651Z" fill="#15212d" stroke="#15212d" stroke-width="1.51"/><path d="M1106,477L1149,365L1040,413Z" fill="#14122d" stroke="#14122d" stroke-width="1.51"/><path d="M1262,481L1106,477L1098,562Z" fill="#150e2c" stroke="#150e2c" stroke-width="1.51"/><path d="M689,-114L545,-110L644,-20Z" fill="#1b2427" stroke="#1b2427" stroke-width="1.51"/><path d="M644,-20L545,-110L566,23Z" fill="#19272a" stroke="#19272a" stroke-width="1.51"/><path d="M1028,16L855,-47L902,89Z" fill="#19212a" stroke="#19212a" stroke-width="1.51"/><path d="M350,882L427,924L443,781Z" fill="#1b1c2d" stroke="#1b1c2d" stroke-width="1.51"/><path d="M439,-17L296,-6L421,77Z" fill="#142e2c" stroke="#142e2c" stroke-width="1.51"/><path d="M225,235L80,349L190,365Z" fill="#132a2e" stroke="#132a2e" stroke-width="1.51"/><path d="M689,-114L644,-20L755,-6Z" fill="#192229" stroke="#192229" stroke-width="1.51"/><path d="M439,-17L324,-108L296,-6Z" fill="#172e29" stroke="#172e29" stroke-width="1.51"/><path d="M753,-160L689,-114L755,-6Z" fill="#1c2127" stroke="#1c2127" stroke-width="1.51"/><path d="M90,203L80,349L225,235Z" fill="#132d2d" stroke="#132d2d" stroke-width="1.51"/><path d="M102,767L201,784L189,651Z" fill="#18202b" stroke="#18202b" stroke-width="1.51"/><path d="M80,349L70,425L190,365Z" fill="#16262d" stroke="#16262d" stroke-width="1.51"/><path d="M1009,122L1028,16L902,89Z" fill="#16212c" stroke="#16212c" stroke-width="1.51"/><path d="M1149,365L1135,188L1018,299Z" fill="#15192e" stroke="#15192e" stroke-width="1.51"/><path d="M296,-6L185,70L328,133Z" fill="#0f332e" stroke="#0f332e" stroke-width="1.51"/><path d="M779,911L893,937L924,797Z" fill="#1b0e2b" stroke="#1b0e2b" stroke-width="1.51"/><path d="M976,767L1153,782L1018,660Z" fill="#170c2a" stroke="#170c2a" stroke-width="1.51"/><path d="M758,1032L893,937L779,911Z" fill="#1f0f2c" stroke="#1f0f2c" stroke-width="1.51"/><path d="M1131,95L1028,16L1009,122Z" fill="#17212c" stroke="#17212c" stroke-width="1.51"/><path d="M855,-47L753,-160L755,-6Z" fill="#1b2128" stroke="#1b2128" stroke-width="1.51"/><path d="M123,103L90,203L225,235Z" fill="#11312e" stroke="#11312e" stroke-width="1.51"/><path d="M80,349L-14,363L70,425Z" fill="#18252b" stroke="#18252b" stroke-width="1.51"/><path d="M1028,16L881,-96L855,-47Z" fill="#1b2028" stroke="#1b2028" stroke-width="1.51"/><path d="M185,70L123,103L225,235Z" fill="#0e342e" stroke="#0e342e" stroke-width="1.51"/><path d="M118,-15L123,103L185,70Z" fill="#15322a" stroke="#15322a" stroke-width="1.51"/><path d="M296,-6L182,-12L185,70Z" fill="#13322a" stroke="#13322a" stroke-width="1.51"/><path d="M545,-110L463,-146L439,-17Z" fill="#1a2927" stroke="#1a2927" stroke-width="1.51"/><path d="M753,-160L463,-146L545,-110Z" fill="#1d2525" stroke="#1d2525" stroke-width="1.51"/><path d="M753,-160L545,-110L689,-114Z" fill="#1d2226" stroke="#1d2226" stroke-width="1.51"/><path d="M122,584L64,646L189,651Z" fill="#18212b" stroke="#18212b" stroke-width="1.51"/><path d="M-55,555L64,646L122,584Z" fill="#1a2029" stroke="#1a2029" stroke-width="1.51"/><path d="M465,1018L566,1030L527,877Z" fill="#20182d" stroke="#20182d" stroke-width="1.51"/><path d="M465,1018L527,877L427,924Z" fill="#1f1a2d" stroke="#1f1a2d" stroke-width="1.51"/><path d="M465,1018L427,924L298,1048Z" fill="#211c2d" stroke="#211c2d" stroke-width="1.51"/><path d="M881,-96L753,-160L855,-47Z" fill="#1c2026" stroke="#1c2026" stroke-width="1.51"/><path d="M1248,72L1131,95L1135,188Z" fill="#16212d" stroke="#16212d" stroke-width="1.51"/><path d="M1135,188L1131,95L1009,122Z" fill="#15222e" stroke="#15222e" stroke-width="1.51"/><path d="M298,1048L427,924L350,882Z" fill="#1f1e2d" stroke="#1f1e2d" stroke-width="1.51"/><path d="M463,-146L324,-108L439,-17Z" fill="#1a2c27" stroke="#1a2c27" stroke-width="1.51"/><path d="M696,1054L758,1032L675,877Z" fill="#20102d" stroke="#20102d" stroke-width="1.51"/><path d="M201,784L170,884L350,882Z" fill="#1b212d" stroke="#1b212d" stroke-width="1.51"/><path d="M64,646L102,767L189,651Z" fill="#18202a" stroke="#18202a" stroke-width="1.51"/><path d="M913,1005L1032,900L893,937Z" fill="#1f0d2b" stroke="#1f0d2b" stroke-width="1.51"/><path d="M893,937L1032,900L924,797Z" fill="#1c0d2b" stroke="#1c0d2b" stroke-width="1.51"/><path d="M207,-137L182,-12L296,-6Z" fill="#173127" stroke="#173127" stroke-width="1.51"/><path d="M566,1030L696,1054L675,877Z" fill="#20132d" stroke="#20132d" stroke-width="1.51"/><path d="M298,1048L696,1054L566,1030Z" fill="#23182d" stroke="#23182d" stroke-width="1.51"/><path d="M1089,882L1153,782L976,767Z" fill="#1a0b2a" stroke="#1a0b2a" stroke-width="1.51"/><path d="M1255,588L1262,481L1098,562Z" fill="#160d2c" stroke="#160d2c" stroke-width="1.51"/><path d="M70,425L-21,485L122,584Z" fill="#19222b" stroke="#19222b" stroke-width="1.51"/><path d="M-5,194L-14,363L80,349Z" fill="#18292a" stroke="#18292a" stroke-width="1.51"/><path d="M-5,194L80,349L90,203Z" fill="#162d2b" stroke="#162d2b" stroke-width="1.51"/><path d="M-5,194L90,203L123,103Z" fill="#13312b" stroke="#13312b" stroke-width="1.51"/><path d="M1255,588L1098,562L1095,646Z" fill="#170d2b" stroke="#170d2b" stroke-width="1.51"/><path d="M1149,365L1263,231L1135,188Z" fill="#161a2d" stroke="#161a2d" stroke-width="1.51"/><path d="M102,767L170,884L201,784Z" fill="#1b202a" stroke="#1b202a" stroke-width="1.51"/><path d="M758,1032L913,1005L893,937Z" fill="#200e2c" stroke="#200e2c" stroke-width="1.51"/><path d="M990,1060L913,1005L758,1032Z" fill="#220e2b" stroke="#220e2b" stroke-width="1.51"/><path d="M64,646L-24,765L102,767Z" fill="#1b1f27" stroke="#1b1f27" stroke-width="1.51"/><path d="M-14,363L-21,485L70,425Z" fill="#192329" stroke="#192329" stroke-width="1.51"/><path d="M1094,1029L1089,882L1032,900Z" fill="#1f0b2a" stroke="#1f0b2a" stroke-width="1.51"/><path d="M1032,900L1089,882L976,767Z" fill="#1b0c2a" stroke="#1b0c2a" stroke-width="1.51"/><path d="M1248,648L1255,588L1095,646Z" fill="#170c2a" stroke="#170c2a" stroke-width="1.51"/><path d="M1318,235L1271,354L1377,345Z" fill="#1b182d" stroke="#1b182d" stroke-width="1.51"/><path d="M1262,481L1271,354L1149,365Z" fill="#17122d" stroke="#17122d" stroke-width="1.51"/><path d="M1253,799L1248,648L1153,782Z" fill="#190a29" stroke="#190a29" stroke-width="1.51"/><path d="M1153,782L1248,648L1095,646Z" fill="#180b2a" stroke="#180b2a" stroke-width="1.51"/><path d="M1131,95L1110,-28L1028,16Z" fill="#192029" stroke="#192029" stroke-width="1.51"/><path d="M1028,16L971,-156L881,-96Z" fill="#1c1f26" stroke="#1c1f26" stroke-width="1.51"/><path d="M881,-96L971,-156L753,-160Z" fill="#1e1f24" stroke="#1e1f24" stroke-width="1.51"/><path d="M1248,72L1110,-28L1131,95Z" fill="#191f29" stroke="#191f29" stroke-width="1.51"/><path d="M1271,354L1263,231L1149,365Z" fill="#17172d" stroke="#17172d" stroke-width="1.51"/><path d="M-121,448L-55,555L-21,485Z" fill="#1c2027" stroke="#1c2027" stroke-width="1.51"/><path d="M-38,73L-5,194L123,103Z" fill="#153229" stroke="#153229" stroke-width="1.51"/><path d="M182,-12L118,-15L185,70Z" fill="#163228" stroke="#163228" stroke-width="1.51"/><path d="M207,-137L118,-15L182,-12Z" fill="#193125" stroke="#193125" stroke-width="1.51"/><path d="M1110,-28L971,-156L1028,16Z" fill="#1c1f26" stroke="#1c1f26" stroke-width="1.51"/><path d="M465,1018L298,1048L566,1030Z" fill="#231b2d" stroke="#231b2d" stroke-width="1.51"/><path d="M170,884L228,990L350,882Z" fill="#1e212d" stroke="#1e212d" stroke-width="1.51"/><path d="M93,915L228,990L170,884Z" fill="#20202a" stroke="#20202a" stroke-width="1.51"/><path d="M-21,485L-55,555L122,584Z" fill="#1a2129" stroke="#1a2129" stroke-width="1.51"/><path d="M102,767L93,915L170,884Z" fill="#1e2029" stroke="#1e2029" stroke-width="1.51"/><path d="M-121,448L-21,485L-14,363Z" fill="#1b2228" stroke="#1b2228" stroke-width="1.51"/><path d="M228,990L298,1048L350,882Z" fill="#21212d" stroke="#21212d" stroke-width="1.51"/><path d="M-55,555L-35,672L64,646Z" fill="#1b1f27" stroke="#1b1f27" stroke-width="1.51"/><path d="M324,-108L207,-137L296,-6Z" fill="#183127" stroke="#183127" stroke-width="1.51"/><path d="M463,-146L207,-137L324,-108Z" fill="#1a2e25" stroke="#1a2e25" stroke-width="1.51"/><path d="M73,-154L207,-137L463,-146Z" fill="#1a3024" stroke="#1a3024" stroke-width="1.51"/><path d="M-24,765L93,915L102,767Z" fill="#1d1f27" stroke="#1d1f27" stroke-width="1.51"/><path d="M228,990L72,1047L298,1048Z" fill="#25212b" stroke="#25212b" stroke-width="1.51"/><path d="M1110,-28L1136,-111L971,-156Z" fill="#1e1e24" stroke="#1e1e24" stroke-width="1.51"/><path d="M1263,231L1248,72L1135,188Z" fill="#171f2d" stroke="#171f2d" stroke-width="1.51"/><path d="M1263,231L1348,116L1248,72Z" fill="#1a212d" stroke="#1a212d" stroke-width="1.51"/><path d="M1271,354L1318,235L1263,231Z" fill="#19192d" stroke="#19192d" stroke-width="1.51"/><path d="M1377,345L1271,354L1386,431Z" fill="#1b142d" stroke="#1b142d" stroke-width="1.51"/><path d="M1355,543L1262,481L1255,588Z" fill="#190e2c" stroke="#190e2c" stroke-width="1.51"/><path d="M-132,680L-24,765L-35,672Z" fill="#1e1e25" stroke="#1e1e25" stroke-width="1.51"/><path d="M-35,672L-24,765L64,646Z" fill="#1c1e27" stroke="#1c1e27" stroke-width="1.51"/><path d="M913,1005L990,1060L1032,900Z" fill="#200d2a" stroke="#200d2a" stroke-width="1.51"/><path d="M1218,912L1253,799L1153,782Z" fill="#1c0a29" stroke="#1c0a29" stroke-width="1.51"/><path d="M696,1054L990,1060L758,1032Z" fill="#220f2c" stroke="#220f2c" stroke-width="1.51"/><path d="M-136,1054L990,1060L696,1054Z" fill="#24182d" stroke="#24182d" stroke-width="1.51"/><path d="M1218,912L1153,782L1089,882Z" fill="#1c0a29" stroke="#1c0a29" stroke-width="1.51"/><path d="M1248,648L1355,543L1255,588Z" fill="#190d2b" stroke="#190d2b" stroke-width="1.51"/><path d="M1367,710L1355,543L1248,648Z" fill="#1b0c2a" stroke="#1b0c2a" stroke-width="1.51"/><path d="M-55,555L-132,680L-35,672Z" fill="#1d1e25" stroke="#1d1e25" stroke-width="1.51"/><path d="M-131,315L-121,448L-14,363Z" fill="#1c2426" stroke="#1c2426" stroke-width="1.51"/><path d="M1246,-36L1136,-111L1110,-28Z" fill="#1d1e25" stroke="#1d1e25" stroke-width="1.51"/><path d="M118,-15L-38,73L123,103Z" fill="#183128" stroke="#183128" stroke-width="1.51"/><path d="M-5,194L-131,315L-14,363Z" fill="#1a2928" stroke="#1a2928" stroke-width="1.51"/><path d="M-39,7L-38,73L118,-15Z" fill="#1a3025" stroke="#1a3025" stroke-width="1.51"/><path d="M1324,-3L1246,-36L1248,72Z" fill="#1e1f28" stroke="#1e1f28" stroke-width="1.51"/><path d="M1386,431L1271,354L1262,481Z" fill="#19112d" stroke="#19112d" stroke-width="1.51"/><path d="M990,1060L1094,1029L1032,900Z" fill="#210c2a" stroke="#210c2a" stroke-width="1.51"/><path d="M-124,219L-131,315L-5,194Z" fill="#1a2b26" stroke="#1a2b26" stroke-width="1.51"/><path d="M1355,543L1386,431L1262,481Z" fill="#1b0f2c" stroke="#1b0f2c" stroke-width="1.51"/><path d="M1094,1029L1218,912L1089,882Z" fill="#1f0a29" stroke="#1f0a29" stroke-width="1.51"/><path d="M1253,799L1367,710L1248,648Z" fill="#1b0b29" stroke="#1b0b29" stroke-width="1.51"/><path d="M-38,73L-124,219L-5,194Z" fill="#183027" stroke="#183027" stroke-width="1.51"/><path d="M1248,72L1246,-36L1110,-28Z" fill="#1b1f27" stroke="#1b1f27" stroke-width="1.51"/><path d="M1348,116L1263,231L1318,235Z" fill="#1a1e2d" stroke="#1a1e2d" stroke-width="1.51"/><path d="M-38,73L-167,117L-124,219Z" fill="#193125" stroke="#193125" stroke-width="1.51"/><path d="M-7,-152L-39,7L118,-15Z" fill="#1d3021" stroke="#1d3021" stroke-width="1.51"/><path d="M1465,118L1348,116L1318,235Z" fill="#1d202d" stroke="#1d202d" stroke-width="1.51"/><path d="M-121,448L-165,529L-55,555Z" fill="#1d1f25" stroke="#1d1f25" stroke-width="1.51"/><path d="M-131,315L-165,529L-121,448Z" fill="#1d2125" stroke="#1d2125" stroke-width="1.51"/><path d="M-167,117L-165,529L-131,315Z" fill="#1d2724" stroke="#1d2724" stroke-width="1.51"/><path d="M1476,551L1473,419L1386,431Z" fill="#1e102d" stroke="#1e102d" stroke-width="1.51"/><path d="M1335,825L1367,710L1253,799Z" fill="#1d0a29" stroke="#1d0a29" stroke-width="1.51"/><path d="M-165,529L-132,680L-55,555Z" fill="#1e1f24" stroke="#1e1f24" stroke-width="1.51"/><path d="M-24,765L-15,943L93,915Z" fill="#211e26" stroke="#211e26" stroke-width="1.51"/><path d="M753,-160L73,-154L463,-146Z" fill="#1c2a24" stroke="#1c2a24" stroke-width="1.51"/><path d="M207,-137L73,-154L118,-15Z" fill="#1b3023" stroke="#1b3023" stroke-width="1.51"/><path d="M1218,912L1335,825L1253,799Z" fill="#1e0a29" stroke="#1e0a29" stroke-width="1.51"/><path d="M1344,904L1335,825L1218,912Z" fill="#200a29" stroke="#200a29" stroke-width="1.51"/><path d="M-175,829L-15,943L-24,765Z" fill="#211d23" stroke="#211d23" stroke-width="1.51"/><path d="M93,915L72,1047L228,990Z" fill="#242029" stroke="#242029" stroke-width="1.51"/><path d="M1094,1029L1223,992L1218,912Z" fill="#210a29" stroke="#210a29" stroke-width="1.51"/><path d="M1339,1016L1223,992L1094,1029Z" fill="#230a29" stroke="#230a29" stroke-width="1.51"/><path d="M1348,116L1324,-3L1248,72Z" fill="#1d202a" stroke="#1d202a" stroke-width="1.51"/><path d="M1246,-36L1241,-144L1136,-111Z" fill="#1f1d23" stroke="#1f1d23" stroke-width="1.51"/><path d="M-15,943L72,1047L93,915Z" fill="#241f27" stroke="#241f27" stroke-width="1.51"/><path d="M298,1048L-136,1054L696,1054Z" fill="#24212d" stroke="#24212d" stroke-width="1.51"/><path d="M1384,-153L1241,-144L1246,-36Z" fill="#211d23" stroke="#211d23" stroke-width="1.51"/><path d="M1136,-111L1241,-144L971,-156Z" fill="#1f1c22" stroke="#1f1c22" stroke-width="1.51"/><path d="M-124,219L-167,117L-131,315Z" fill="#1b2c25" stroke="#1b2c25" stroke-width="1.51"/><path d="M-117,3L-167,117L-38,73Z" fill="#1c3022" stroke="#1c3022" stroke-width="1.51"/><path d="M-117,3L-38,73L-39,7Z" fill="#1c3022" stroke="#1c3022" stroke-width="1.51"/><path d="M1501,228L1465,118L1318,235Z" fill="#1f1e2d" stroke="#1f1e2d" stroke-width="1.51"/><path d="M1348,116L1465,118L1324,-3Z" fill="#1f202b" stroke="#1f202b" stroke-width="1.51"/><path d="M1386,431L1476,362L1377,345Z" fill="#1d142d" stroke="#1d142d" stroke-width="1.51"/><path d="M1476,551L1386,431L1355,543Z" fill="#1d0e2c" stroke="#1d0e2c" stroke-width="1.51"/><path d="M1476,551L1355,543L1459,701Z" fill="#1e0d2b" stroke="#1e0d2b" stroke-width="1.51"/><path d="M-110,-95L-117,3L-39,7Z" fill="#1f2e1e" stroke="#1f2e1e" stroke-width="1.51"/><path d="M1473,419L1476,362L1386,431Z" fill="#1e132d" stroke="#1e132d" stroke-width="1.51"/><path d="M73,-154L-7,-152L118,-15Z" fill="#1e2f20" stroke="#1e2f20" stroke-width="1.51"/><path d="M1459,701L1355,543L1367,710Z" fill="#1d0c2a" stroke="#1d0c2a" stroke-width="1.51"/><path d="M1579,184L1501,228L1612,342Z" fill="#231c2d" stroke="#231c2d" stroke-width="1.51"/><path d="M1467,827L1459,701L1367,710Z" fill="#1f0a29" stroke="#1f0a29" stroke-width="1.51"/><path d="M1223,992L1344,904L1218,912Z" fill="#220a29" stroke="#220a29" stroke-width="1.51"/><path d="M-15,943L-43,1000L72,1047Z" fill="#261e25" stroke="#261e25" stroke-width="1.51"/><path d="M-145,939L-43,1000L-15,943Z" fill="#261e23" stroke="#261e23" stroke-width="1.51"/><path d="M1467,827L1367,710L1335,825Z" fill="#1f0a29" stroke="#1f0a29" stroke-width="1.51"/><path d="M-167,117L-175,829L-165,529Z" fill="#1e1f24" stroke="#1e1f24" stroke-width="1.51"/><path d="M-165,529L-175,829L-132,680Z" fill="#1f1d22" stroke="#1f1d22" stroke-width="1.51"/><path d="M-132,680L-175,829L-24,765Z" fill="#1e1c22" stroke="#1e1c22" stroke-width="1.51"/><path d="M1501,228L1318,235L1377,345Z" fill="#1d1a2d" stroke="#1d1a2d" stroke-width="1.51"/><path d="M1324,-3L1384,-153L1246,-36Z" fill="#211e25" stroke="#211e25" stroke-width="1.51"/><path d="M1476,362L1501,228L1377,345Z" fill="#1f182d" stroke="#1f182d" stroke-width="1.51"/><path d="M1567,465L1476,362L1473,419Z" fill="#20122d" stroke="#20122d" stroke-width="1.51"/><path d="M-7,-152L-110,-95L-39,7Z" fill="#1f2e1e" stroke="#1f2e1e" stroke-width="1.51"/><path d="M-117,3L-110,-95L-167,117Z" fill="#1e2f1f" stroke="#1e2f1f" stroke-width="1.51"/><path d="M-175,829L-145,939L-15,943Z" fill="#241d22" stroke="#241d22" stroke-width="1.51"/><path d="M1344,904L1467,827L1335,825Z" fill="#210a29" stroke="#210a29" stroke-width="1.51"/><path d="M1223,992L1339,1016L1344,904Z" fill="#240a29" stroke="#240a29" stroke-width="1.51"/><path d="M990,1060L1339,1016L1094,1029Z" fill="#220b29" stroke="#220b29" stroke-width="1.51"/><path d="M1484,941L1467,827L1344,904Z" fill="#240a29" stroke="#240a29" stroke-width="1.51"/><path d="M1582,529L1567,465L1476,551Z" fill="#210e2c" stroke="#210e2c" stroke-width="1.51"/><path d="M1476,551L1567,465L1473,419Z" fill="#200f2c" stroke="#200f2c" stroke-width="1.51"/><path d="M1582,529L1476,551L1577,710Z" fill="#220d2b" stroke="#220d2b" stroke-width="1.51"/><path d="M1481,-21L1324,-3L1465,118Z" fill="#222029" stroke="#222029" stroke-width="1.51"/><path d="M1481,-21L1384,-153L1324,-3Z" fill="#231e25" stroke="#231e25" stroke-width="1.51"/><path d="M1241,-144L1384,-153L971,-156Z" fill="#201c21" stroke="#201c21" stroke-width="1.51"/><path d="M971,-156L1384,-153L753,-160Z" fill="#1f1d22" stroke="#1f1d22" stroke-width="1.51"/><path d="M1577,710L1476,551L1459,701Z" fill="#210d2a" stroke="#210d2a" stroke-width="1.51"/><path d="M1501,228L1476,362L1612,342Z" fill="#21182d" stroke="#21182d" stroke-width="1.51"/><path d="M1579,184L1465,118L1501,228Z" fill="#211f2d" stroke="#211f2d" stroke-width="1.51"/><path d="M1568,-35L1481,-21L1465,118Z" fill="#252028" stroke="#252028" stroke-width="1.51"/><path d="M-175,829L-136,1054L-145,939Z" fill="#271d21" stroke="#271d21" stroke-width="1.51"/><path d="M-145,939L-136,1054L-43,1000Z" fill="#281d22" stroke="#281d22" stroke-width="1.51"/><path d="M-43,1000L-136,1054L72,1047Z" fill="#291f24" stroke="#291f24" stroke-width="1.51"/><path d="M72,1047L-136,1054L298,1048Z" fill="#272028" stroke="#272028" stroke-width="1.51"/><path d="M1612,342L1476,362L1567,465Z" fill="#21132d" stroke="#21132d" stroke-width="1.51"/><path d="M1582,529L1612,342L1567,465Z" fill="#22102d" stroke="#22102d" stroke-width="1.51"/><path d="M1582,529L1577,710L1612,342Z" fill="#220f2c" stroke="#220f2c" stroke-width="1.51"/><path d="M1565,807L1577,710L1459,701Z" fill="#210a29" stroke="#210a29" stroke-width="1.51"/><path d="M1565,807L1459,701L1467,827Z" fill="#210a29" stroke="#210a29" stroke-width="1.51"/><path d="M1456,1026L1484,941L1344,904Z" fill="#270a29" stroke="#270a29" stroke-width="1.51"/><path d="M1577,908L1565,807L1467,827Z" fill="#250a29" stroke="#250a29" stroke-width="1.51"/><path d="M1484,941L1577,908L1467,827Z" fill="#260a29" stroke="#260a29" stroke-width="1.51"/><path d="M1339,1016L1456,1026L1344,904Z" fill="#260a29" stroke="#260a29" stroke-width="1.51"/><path d="M990,1060L1456,1026L1339,1016Z" fill="#250a29" stroke="#250a29" stroke-width="1.51"/><path d="M1481,-21L1493,-148L1384,-153Z" fill="#261d23" stroke="#261d23" stroke-width="1.51"/><path d="M1568,-35L1493,-148L1481,-21Z" fill="#271e25" stroke="#271e25" stroke-width="1.51"/><path d="M1579,184L1618,111L1465,118Z" fill="#23222d" stroke="#23222d" stroke-width="1.51"/><path d="M1612,342L1618,111L1579,184Z" fill="#241e2d" stroke="#241e2d" stroke-width="1.51"/><path d="M1618,111L1568,-35L1465,118Z" fill="#25212a" stroke="#25212a" stroke-width="1.51"/><path d="M1568,1003L1577,908L1484,941Z" fill="#2a0a29" stroke="#2a0a29" stroke-width="1.51"/><path d="M1565,807L1577,908L1577,710Z" fill="#240a29" stroke="#240a29" stroke-width="1.51"/><path d="M1577,710L1577,908L1612,342Z" fill="#230d2a" stroke="#230d2a" stroke-width="1.51"/><path d="M1456,1026L1568,1003L1484,941Z" fill="#2a0a29" stroke="#2a0a29" stroke-width="1.51"/><path d="M1568,-35L1582,-127L1493,-148Z" fill="#281e23" stroke="#281e23" stroke-width="1.51"/><path d="M1618,111L1582,-127L1568,-35Z" fill="#281f26" stroke="#281f26" stroke-width="1.51"/></svg>
\ No newline at end of file diff --git a/src/client/assets/welcome-bg.light.svg b/src/client/assets/welcome-bg.light.svg deleted file mode 100644 index ebccb648ea..0000000000 --- a/src/client/assets/welcome-bg.light.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="900"><path d="M741,416L678,396L681,478Z" fill="#f4f4f3" stroke="#f4f4f3" stroke-width="1.51"/><path d="M681,478L777,499L741,416Z" fill="#f2f3f2" stroke="#f2f3f2" stroke-width="1.51"/><path d="M678,396L605,487L681,478Z" fill="#f3f3f2" stroke="#f3f3f2" stroke-width="1.51"/><path d="M681,478L683,560L777,499Z" fill="#edf1ee" stroke="#edf1ee" stroke-width="1.51"/><path d="M760,327L659,322L678,396Z" fill="#f1f1ef" stroke="#f1f1ef" stroke-width="1.51"/><path d="M678,396L571,389L605,487Z" fill="#f2f2f0" stroke="#f2f2f0" stroke-width="1.51"/><path d="M741,416L760,327L678,396Z" fill="#f3f2f1" stroke="#f3f2f1" stroke-width="1.51"/><path d="M854,398L760,327L741,416Z" fill="#eef0ec" stroke="#eef0ec" stroke-width="1.51"/><path d="M605,487L683,560L681,478Z" fill="#edf0ec" stroke="#edf0ec" stroke-width="1.51"/><path d="M659,322L571,389L678,396Z" fill="#f1f1ee" stroke="#f1f1ee" stroke-width="1.51"/><path d="M683,560L783,568L777,499Z" fill="#e7eee9" stroke="#e7eee9" stroke-width="1.51"/><path d="M777,499L854,398L741,416Z" fill="#eef2ef" stroke="#eef2ef" stroke-width="1.51"/><path d="M844,505L854,398L777,499Z" fill="#eaefeb" stroke="#eaefeb" stroke-width="1.51"/><path d="M783,568L844,505L777,499Z" fill="#e6ece7" stroke="#e6ece7" stroke-width="1.51"/><path d="M659,322L596,308L571,389Z" fill="#f0f0eb" stroke="#f0f0eb" stroke-width="1.51"/><path d="M597,246L596,308L659,322Z" fill="#efeee9" stroke="#efeee9" stroke-width="1.51"/><path d="M605,487L579,600L683,560Z" fill="#e7ede7" stroke="#e7ede7" stroke-width="1.51"/><path d="M571,389L512,495L605,487Z" fill="#f1f1ee" stroke="#f1f1ee" stroke-width="1.51"/><path d="M492,390L512,495L571,389Z" fill="#f0f0ec" stroke="#f0f0ec" stroke-width="1.51"/><path d="M760,327L745,238L659,322Z" fill="#f1f0ed" stroke="#f1f0ed" stroke-width="1.51"/><path d="M852,301L745,238L760,327Z" fill="#ebede7" stroke="#ebede7" stroke-width="1.51"/><path d="M512,495L579,600L605,487Z" fill="#e9ede7" stroke="#e9ede7" stroke-width="1.51"/><path d="M764,652L848,599L783,568Z" fill="#dce7de" stroke="#dce7de" stroke-width="1.51"/><path d="M854,398L852,301L760,327Z" fill="#eaeee8" stroke="#eaeee8" stroke-width="1.51"/><path d="M513,332L492,390L571,389Z" fill="#efeeea" stroke="#efeeea" stroke-width="1.51"/><path d="M596,308L513,332L571,389Z" fill="#efefe9" stroke="#efefe9" stroke-width="1.51"/><path d="M521,240L513,332L596,308Z" fill="#edede6" stroke="#edede6" stroke-width="1.51"/><path d="M655,216L597,246L659,322Z" fill="#eeeee8" stroke="#eeeee8" stroke-width="1.51"/><path d="M783,568L848,599L844,505Z" fill="#e0e9e1" stroke="#e0e9e1" stroke-width="1.51"/><path d="M844,505L926,479L854,398Z" fill="#e7ede8" stroke="#e7ede8" stroke-width="1.51"/><path d="M764,652L783,568L683,560Z" fill="#e2eae3" stroke="#e2eae3" stroke-width="1.51"/><path d="M745,238L655,216L659,322Z" fill="#efefea" stroke="#efefea" stroke-width="1.51"/><path d="M691,673L764,652L683,560Z" fill="#e0e9e1" stroke="#e0e9e1" stroke-width="1.51"/><path d="M930,580L926,479L844,505Z" fill="#dee8e0" stroke="#dee8e0" stroke-width="1.51"/><path d="M854,398L924,327L852,301Z" fill="#e5ebe3" stroke="#e5ebe3" stroke-width="1.51"/><path d="M926,479L932,405L854,398Z" fill="#e5ece6" stroke="#e5ece6" stroke-width="1.51"/><path d="M579,600L691,673L683,560Z" fill="#e1e9e1" stroke="#e1e9e1" stroke-width="1.51"/><path d="M852,301L854,250L745,238Z" fill="#e8ebe4" stroke="#e8ebe4" stroke-width="1.51"/><path d="M745,238L695,139L655,216Z" fill="#eeede7" stroke="#eeede7" stroke-width="1.51"/><path d="M932,252L854,250L852,301Z" fill="#e3e9df" stroke="#e3e9df" stroke-width="1.51"/><path d="M932,405L924,327L854,398Z" fill="#e4ebe3" stroke="#e4ebe3" stroke-width="1.51"/><path d="M512,495L484,563L579,600Z" fill="#e5ebe3" stroke="#e5ebe3" stroke-width="1.51"/><path d="M579,600L598,675L691,673Z" fill="#dce6dc" stroke="#dce6dc" stroke-width="1.51"/><path d="M424,483L484,563L512,495Z" fill="#e9ece5" stroke="#e9ece5" stroke-width="1.51"/><path d="M523,666L598,675L579,600Z" fill="#dbe6da" stroke="#dbe6da" stroke-width="1.51"/><path d="M597,246L521,240L596,308Z" fill="#edede6" stroke="#edede6" stroke-width="1.51"/><path d="M403,323L433,428L492,390Z" fill="#eeede7" stroke="#eeede7" stroke-width="1.51"/><path d="M509,138L521,240L597,246Z" fill="#ebeae2" stroke="#ebeae2" stroke-width="1.51"/><path d="M492,390L433,428L512,495Z" fill="#f0efec" stroke="#f0efec" stroke-width="1.51"/><path d="M403,323L492,390L513,332Z" fill="#edede7" stroke="#edede7" stroke-width="1.51"/><path d="M848,599L930,580L844,505Z" fill="#dce7dd" stroke="#dce7dd" stroke-width="1.51"/><path d="M926,479L1021,423L932,405Z" fill="#e1e9e2" stroke="#e1e9e2" stroke-width="1.51"/><path d="M914,673L930,580L848,599Z" fill="#d3e2d5" stroke="#d3e2d5" stroke-width="1.51"/><path d="M433,428L424,483L512,495Z" fill="#eeefe9" stroke="#eeefe9" stroke-width="1.51"/><path d="M439,658L523,666L484,563Z" fill="#dce5d9" stroke="#dce5d9" stroke-width="1.51"/><path d="M484,563L523,666L579,600Z" fill="#dee7dd" stroke="#dee7dd" stroke-width="1.51"/><path d="M764,652L859,684L848,599Z" fill="#d6e4d8" stroke="#d6e4d8" stroke-width="1.51"/><path d="M841,752L859,684L764,652Z" fill="#d0e0d3" stroke="#d0e0d3" stroke-width="1.51"/><path d="M771,770L764,652L691,673Z" fill="#d6e4d9" stroke="#d6e4d9" stroke-width="1.51"/><path d="M781,131L695,139L745,238Z" fill="#ecece5" stroke="#ecece5" stroke-width="1.51"/><path d="M924,327L932,252L852,301Z" fill="#e2e8df" stroke="#e2e8df" stroke-width="1.51"/><path d="M1000,247L932,252L924,327Z" fill="#dde6da" stroke="#dde6da" stroke-width="1.51"/><path d="M781,131L745,238L854,250Z" fill="#e8eae3" stroke="#e8eae3" stroke-width="1.51"/><path d="M655,216L610,125L597,246Z" fill="#ecece4" stroke="#ecece4" stroke-width="1.51"/><path d="M869,153L781,131L854,250Z" fill="#e4e8de" stroke="#e4e8de" stroke-width="1.51"/><path d="M521,240L403,323L513,332Z" fill="#ecebe4" stroke="#ecebe4" stroke-width="1.51"/><path d="M342,409L347,500L424,483Z" fill="#edede7" stroke="#edede7" stroke-width="1.51"/><path d="M930,580L1009,511L926,479Z" fill="#d9e6db" stroke="#d9e6db" stroke-width="1.51"/><path d="M932,405L1014,332L924,327Z" fill="#dfe8de" stroke="#dfe8de" stroke-width="1.51"/><path d="M1044,592L1009,511L930,580Z" fill="#d1e1d4" stroke="#d1e1d4" stroke-width="1.51"/><path d="M859,684L914,673L848,599Z" fill="#d1e1d4" stroke="#d1e1d4" stroke-width="1.51"/><path d="M1009,511L1021,423L926,479Z" fill="#dde7de" stroke="#dde7de" stroke-width="1.51"/><path d="M424,483L415,588L484,563Z" fill="#e5eae1" stroke="#e5eae1" stroke-width="1.51"/><path d="M347,500L415,588L424,483Z" fill="#e6eae1" stroke="#e6eae1" stroke-width="1.51"/><path d="M695,139L610,125L655,216Z" fill="#ecebe3" stroke="#ecebe3" stroke-width="1.51"/><path d="M521,240L403,213L403,323Z" fill="#eaeae1" stroke="#eaeae1" stroke-width="1.51"/><path d="M659,40L610,125L695,139Z" fill="#ebe7df" stroke="#ebe7df" stroke-width="1.51"/><path d="M598,675L697,764L691,673Z" fill="#d6e3d8" stroke="#d6e3d8" stroke-width="1.51"/><path d="M859,684L935,739L914,673Z" fill="#c9ddcd" stroke="#c9ddcd" stroke-width="1.51"/><path d="M523,666L582,742L598,675Z" fill="#d5e2d5" stroke="#d5e2d5" stroke-width="1.51"/><path d="M504,768L582,742L523,666Z" fill="#d1e0d1" stroke="#d1e0d1" stroke-width="1.51"/><path d="M582,742L697,764L598,675Z" fill="#d3e1d4" stroke="#d3e1d4" stroke-width="1.51"/><path d="M932,252L869,153L854,250Z" fill="#e1e7dd" stroke="#e1e7dd" stroke-width="1.51"/><path d="M1021,423L1014,332L932,405Z" fill="#dde7dd" stroke="#dde7dd" stroke-width="1.51"/><path d="M932,252L934,126L869,153Z" fill="#dee5d9" stroke="#dee5d9" stroke-width="1.51"/><path d="M697,764L771,770L691,673Z" fill="#d4e2d6" stroke="#d4e2d6" stroke-width="1.51"/><path d="M415,588L439,658L484,563Z" fill="#dee6da" stroke="#dee6da" stroke-width="1.51"/><path d="M771,770L841,752L764,652Z" fill="#cfe0d2" stroke="#cfe0d2" stroke-width="1.51"/><path d="M610,125L509,138L597,246Z" fill="#eaeae1" stroke="#eaeae1" stroke-width="1.51"/><path d="M1014,332L1000,247L924,327Z" fill="#dce6da" stroke="#dce6da" stroke-width="1.51"/><path d="M342,409L424,483L433,428Z" fill="#eeeee9" stroke="#eeeee9" stroke-width="1.51"/><path d="M415,588L317,676L439,658Z" fill="#d9e2d4" stroke="#d9e2d4" stroke-width="1.51"/><path d="M403,323L342,409L433,428Z" fill="#edece5" stroke="#edece5" stroke-width="1.51"/><path d="M318,300L342,409L403,323Z" fill="#ebebe2" stroke="#ebebe2" stroke-width="1.51"/><path d="M438,164L403,213L521,240Z" fill="#e9e9df" stroke="#e9e9df" stroke-width="1.51"/><path d="M1009,511L1102,487L1021,423Z" fill="#d7e5da" stroke="#d7e5da" stroke-width="1.51"/><path d="M1021,423L1106,419L1014,332Z" fill="#d9e4d9" stroke="#d9e4d9" stroke-width="1.51"/><path d="M1039,662L1044,592L930,580Z" fill="#cbddce" stroke="#cbddce" stroke-width="1.51"/><path d="M1039,662L930,580L914,673Z" fill="#ccdecf" stroke="#ccdecf" stroke-width="1.51"/><path d="M509,138L438,164L521,240Z" fill="#e9e9de" stroke="#e9e9de" stroke-width="1.51"/><path d="M841,752L935,739L859,684Z" fill="#c8dccc" stroke="#c8dccc" stroke-width="1.51"/><path d="M848,838L935,739L841,752Z" fill="#c3d9c8" stroke="#c3d9c8" stroke-width="1.51"/><path d="M439,658L504,768L523,666Z" fill="#d4e1d2" stroke="#d4e1d2" stroke-width="1.51"/><path d="M582,742L595,833L697,764Z" fill="#ceded0" stroke="#ceded0" stroke-width="1.51"/><path d="M697,764L752,857L771,770Z" fill="#cdded2" stroke="#cdded2" stroke-width="1.51"/><path d="M1000,247L934,126L932,252Z" fill="#dbe4d7" stroke="#dbe4d7" stroke-width="1.51"/><path d="M869,153L847,70L781,131Z" fill="#e4e5da" stroke="#e4e5da" stroke-width="1.51"/><path d="M754,40L659,40L695,139Z" fill="#ece5de" stroke="#ece5de" stroke-width="1.51"/><path d="M925,70L847,70L869,153Z" fill="#dfe1d5" stroke="#dfe1d5" stroke-width="1.51"/><path d="M610,125L596,39L509,138Z" fill="#e9e5dc" stroke="#e9e5dc" stroke-width="1.51"/><path d="M754,40L695,139L781,131Z" fill="#eae7df" stroke="#eae7df" stroke-width="1.51"/><path d="M509,138L481,39L438,164Z" fill="#e7e4d9" stroke="#e7e4d9" stroke-width="1.51"/><path d="M847,70L754,40L781,131Z" fill="#e6e3da" stroke="#e6e3da" stroke-width="1.51"/><path d="M439,658L431,757L504,768Z" fill="#d0dece" stroke="#d0dece" stroke-width="1.51"/><path d="M347,500L320,574L415,588Z" fill="#e2e8dd" stroke="#e2e8dd" stroke-width="1.51"/><path d="M252,484L320,574L347,500Z" fill="#e5e8de" stroke="#e5e8de" stroke-width="1.51"/><path d="M1044,592L1102,487L1009,511Z" fill="#d0e1d4" stroke="#d0e1d4" stroke-width="1.51"/><path d="M1014,332L1093,313L1000,247Z" fill="#d7e2d5" stroke="#d7e2d5" stroke-width="1.51"/><path d="M403,213L318,300L403,323Z" fill="#e9e9df" stroke="#e9e9df" stroke-width="1.51"/><path d="M342,409L252,484L347,500Z" fill="#ecece5" stroke="#ecece5" stroke-width="1.51"/><path d="M336,215L318,300L403,213Z" fill="#e8e8dd" stroke="#e8e8dd" stroke-width="1.51"/><path d="M1102,487L1106,419L1021,423Z" fill="#d8e5da" stroke="#d8e5da" stroke-width="1.51"/><path d="M1000,247L1035,167L934,126Z" fill="#d8e2d3" stroke="#d8e2d3" stroke-width="1.51"/><path d="M935,739L1039,662L914,673Z" fill="#c5dbc9" stroke="#c5dbc9" stroke-width="1.51"/><path d="M1044,592L1121,583L1102,487Z" fill="#cbdece" stroke="#cbdece" stroke-width="1.51"/><path d="M516,826L595,833L582,742Z" fill="#cbdcce" stroke="#cbdcce" stroke-width="1.51"/><path d="M771,770L848,838L841,752Z" fill="#c7dbcc" stroke="#c7dbcc" stroke-width="1.51"/><path d="M659,40L596,39L610,125Z" fill="#eae3db" stroke="#eae3db" stroke-width="1.51"/><path d="M661,-27L596,39L659,40Z" fill="#eadfd7" stroke="#eadfd7" stroke-width="1.51"/><path d="M1106,419L1093,313L1014,332Z" fill="#d6e3d6" stroke="#d6e3d6" stroke-width="1.51"/><path d="M504,768L516,826L582,742Z" fill="#ccddcd" stroke="#ccddcd" stroke-width="1.51"/><path d="M317,676L431,757L439,658Z" fill="#d2dfcf" stroke="#d2dfcf" stroke-width="1.51"/><path d="M595,833L691,858L697,764Z" fill="#cbddd1" stroke="#cbddd1" stroke-width="1.51"/><path d="M691,858L752,857L697,764Z" fill="#ccddd2" stroke="#ccddd2" stroke-width="1.51"/><path d="M353,127L403,213L438,164Z" fill="#e7e6db" stroke="#e7e6db" stroke-width="1.51"/><path d="M353,127L336,215L403,213Z" fill="#e6e6da" stroke="#e6e6da" stroke-width="1.51"/><path d="M752,857L848,838L771,770Z" fill="#c7dacd" stroke="#c7dacd" stroke-width="1.51"/><path d="M935,739L1021,771L1039,662Z" fill="#bfd7c3" stroke="#bfd7c3" stroke-width="1.51"/><path d="M934,126L925,70L869,153Z" fill="#dde1d5" stroke="#dde1d5" stroke-width="1.51"/><path d="M1121,236L1035,167L1000,247Z" fill="#d4e0d0" stroke="#d4e0d0" stroke-width="1.51"/><path d="M857,-32L751,-25L754,40Z" fill="#e6dcd3" stroke="#e6dcd3" stroke-width="1.51"/><path d="M1020,81L925,70L934,126Z" fill="#d9ddce" stroke="#d9ddce" stroke-width="1.51"/><path d="M426,848L516,826L504,768Z" fill="#c8d9ca" stroke="#c8d9ca" stroke-width="1.51"/><path d="M595,833L598,906L691,858Z" fill="#c7d9cd" stroke="#c7d9cd" stroke-width="1.51"/><path d="M1114,656L1121,583L1044,592Z" fill="#c4d9c8" stroke="#c4d9c8" stroke-width="1.51"/><path d="M1102,487L1185,403L1106,419Z" fill="#d3e2d6" stroke="#d3e2d6" stroke-width="1.51"/><path d="M239,415L342,409L232,341Z" fill="#ebeae1" stroke="#ebeae1" stroke-width="1.51"/><path d="M239,415L252,484L342,409Z" fill="#ecebe4" stroke="#ecebe4" stroke-width="1.51"/><path d="M320,574L317,676L415,588Z" fill="#dbe3d6" stroke="#dbe3d6" stroke-width="1.51"/><path d="M265,661L317,676L320,574Z" fill="#d8e1d2" stroke="#d8e1d2" stroke-width="1.51"/><path d="M481,907L598,906L516,826Z" fill="#c4d7ca" stroke="#c4d7ca" stroke-width="1.51"/><path d="M596,39L481,39L509,138Z" fill="#e8e2d8" stroke="#e8e2d8" stroke-width="1.51"/><path d="M232,341L342,409L318,300Z" fill="#eae9e1" stroke="#eae9e1" stroke-width="1.51"/><path d="M1039,662L1114,656L1044,592Z" fill="#c3d9c7" stroke="#c3d9c7" stroke-width="1.51"/><path d="M1003,828L1021,771L935,739Z" fill="#bad4c0" stroke="#bad4c0" stroke-width="1.51"/><path d="M754,40L751,-25L659,40Z" fill="#ebe1da" stroke="#ebe1da" stroke-width="1.51"/><path d="M596,39L496,-7L481,39Z" fill="#e8ddd4" stroke="#e8ddd4" stroke-width="1.51"/><path d="M857,-32L754,40L847,70Z" fill="#e4ddd3" stroke="#e4ddd3" stroke-width="1.51"/><path d="M425,40L353,127L438,164Z" fill="#e6e3d7" stroke="#e6e3d7" stroke-width="1.51"/><path d="M247,249L232,341L318,300Z" fill="#e8e6dc" stroke="#e8e6dc" stroke-width="1.51"/><path d="M751,-25L661,-27L659,40Z" fill="#ebded7" stroke="#ebded7" stroke-width="1.51"/><path d="M1093,313L1121,236L1000,247Z" fill="#d3e0d1" stroke="#d3e0d1" stroke-width="1.51"/><path d="M1035,167L1020,81L934,126Z" fill="#d6decf" stroke="#d6decf" stroke-width="1.51"/><path d="M1213,315L1121,236L1093,313Z" fill="#cedecd" stroke="#cedecd" stroke-width="1.51"/><path d="M1185,403L1093,313L1106,419Z" fill="#d2e1d3" stroke="#d2e1d3" stroke-width="1.51"/><path d="M1093,741L1114,656L1039,662Z" fill="#bcd5c1" stroke="#bcd5c1" stroke-width="1.51"/><path d="M247,249L318,300L336,215Z" fill="#e7e7dc" stroke="#e7e7dc" stroke-width="1.51"/><path d="M1114,139L1020,81L1035,167Z" fill="#d1dccb" stroke="#d1dccb" stroke-width="1.51"/><path d="M925,70L857,-32L847,70Z" fill="#dfdcd0" stroke="#dfdcd0" stroke-width="1.51"/><path d="M661,-27L577,-27L596,39Z" fill="#e9ddd4" stroke="#e9ddd4" stroke-width="1.51"/><path d="M426,848L504,768L431,757Z" fill="#cadaca" stroke="#cadaca" stroke-width="1.51"/><path d="M516,826L598,906L595,833Z" fill="#c6d9cc" stroke="#c6d9cc" stroke-width="1.51"/><path d="M691,858L757,921L752,857Z" fill="#c7dacf" stroke="#c7dacf" stroke-width="1.51"/><path d="M840,910L941,859L848,838Z" fill="#bcd4c5" stroke="#bcd4c5" stroke-width="1.51"/><path d="M496,-7L425,40L481,39Z" fill="#e7dcd2" stroke="#e7dcd2" stroke-width="1.51"/><path d="M481,39L425,40L438,164Z" fill="#e7e1d6" stroke="#e7e1d6" stroke-width="1.51"/><path d="M147,423L252,484L239,415Z" fill="#ece7e0" stroke="#ece7e0" stroke-width="1.51"/><path d="M164,575L241,587L252,484Z" fill="#e1e1d7" stroke="#e1e1d7" stroke-width="1.51"/><path d="M252,484L241,587L320,574Z" fill="#e1e5da" stroke="#e1e5da" stroke-width="1.51"/><path d="M265,661L319,736L317,676Z" fill="#d1ddcc" stroke="#d1ddcc" stroke-width="1.51"/><path d="M317,676L319,736L431,757Z" fill="#cfddcb" stroke="#cfddcb" stroke-width="1.51"/><path d="M1187,514L1102,487L1121,583Z" fill="#caddcd" stroke="#caddcd" stroke-width="1.51"/><path d="M1187,514L1185,403L1102,487Z" fill="#cfe0d3" stroke="#cfe0d3" stroke-width="1.51"/><path d="M848,838L941,859L935,739Z" fill="#bdd5c5" stroke="#bdd5c5" stroke-width="1.51"/><path d="M840,910L848,838L752,857Z" fill="#c1d7ca" stroke="#c1d7ca" stroke-width="1.51"/><path d="M577,-27L496,-7L596,39Z" fill="#e8dcd3" stroke="#e8dcd3" stroke-width="1.51"/><path d="M687,939L757,921L691,858Z" fill="#c5d9cf" stroke="#c5d9cf" stroke-width="1.51"/><path d="M241,587L265,661L320,574Z" fill="#dae2d3" stroke="#dae2d3" stroke-width="1.51"/><path d="M225,125L336,215L353,127Z" fill="#e5e4d7" stroke="#e5e4d7" stroke-width="1.51"/><path d="M225,125L247,249L336,215Z" fill="#e6e4d7" stroke="#e6e4d7" stroke-width="1.51"/><path d="M1184,577L1187,514L1121,583Z" fill="#c5dac9" stroke="#c5dac9" stroke-width="1.51"/><path d="M953,-21L857,-32L925,70Z" fill="#dcd8cb" stroke="#dcd8cb" stroke-width="1.51"/><path d="M751,-25L666,-95L661,-27Z" fill="#ebdad4" stroke="#ebdad4" stroke-width="1.51"/><path d="M661,-27L591,-101L577,-27Z" fill="#e9d9d0" stroke="#e9d9d0" stroke-width="1.51"/><path d="M757,921L840,910L752,857Z" fill="#c1d6cb" stroke="#c1d6cb" stroke-width="1.51"/><path d="M319,736L426,848L431,757Z" fill="#c9dac8" stroke="#c9dac8" stroke-width="1.51"/><path d="M1021,771L1093,741L1039,662Z" fill="#bad5bf" stroke="#bad5bf" stroke-width="1.51"/><path d="M941,859L1003,828L935,739Z" fill="#bad3c1" stroke="#bad3c1" stroke-width="1.51"/><path d="M1043,903L1003,828L941,859Z" fill="#b2cfbd" stroke="#b2cfbd" stroke-width="1.51"/><path d="M1110,818L1093,741L1021,771Z" fill="#b3d0b9" stroke="#b3d0b9" stroke-width="1.51"/><path d="M1114,656L1184,577L1121,583Z" fill="#c1d8c5" stroke="#c1d8c5" stroke-width="1.51"/><path d="M870,1015L917,930L840,910Z" fill="#b5cfc3" stroke="#b5cfc3" stroke-width="1.51"/><path d="M598,906L687,939L691,858Z" fill="#c5d8cd" stroke="#c5d8cd" stroke-width="1.51"/><path d="M683,991L687,939L598,906Z" fill="#c1d5cb" stroke="#c1d5cb" stroke-width="1.51"/><path d="M1184,688L1184,577L1114,656Z" fill="#bbd4c0" stroke="#bbd4c0" stroke-width="1.51"/><path d="M1016,-5L953,-21L925,70Z" fill="#d8d6c7" stroke="#d8d6c7" stroke-width="1.51"/><path d="M1121,236L1114,139L1035,167Z" fill="#d0ddcb" stroke="#d0ddcb" stroke-width="1.51"/><path d="M1190,163L1114,139L1121,236Z" fill="#ccdbc7" stroke="#ccdbc7" stroke-width="1.51"/><path d="M425,40L336,72L353,127Z" fill="#e5dfd3" stroke="#e5dfd3" stroke-width="1.51"/><path d="M343,-19L336,72L425,40Z" fill="#e5dbcf" stroke="#e5dbcf" stroke-width="1.51"/><path d="M426,848L481,907L516,826Z" fill="#c4d6c8" stroke="#c4d6c8" stroke-width="1.51"/><path d="M400,933L481,907L426,848Z" fill="#c1d4c6" stroke="#c1d4c6" stroke-width="1.51"/><path d="M1016,-5L925,70L1020,81Z" fill="#d6d8c9" stroke="#d6d8c9" stroke-width="1.51"/><path d="M1185,403L1213,315L1093,313Z" fill="#cedecf" stroke="#cedecf" stroke-width="1.51"/><path d="M1273,400L1213,315L1185,403Z" fill="#ccdccf" stroke="#ccdccf" stroke-width="1.51"/><path d="M1273,400L1185,403L1293,477Z" fill="#ccddd1" stroke="#ccddd1" stroke-width="1.51"/><path d="M577,-27L492,-108L496,-7Z" fill="#e7d7ce" stroke="#e7d7ce" stroke-width="1.51"/><path d="M496,-7L420,-42L425,40Z" fill="#e6dace" stroke="#e6dace" stroke-width="1.51"/><path d="M772,-110L666,-95L751,-25Z" fill="#ead8d2" stroke="#ead8d2" stroke-width="1.51"/><path d="M772,-110L751,-25L857,-32Z" fill="#e5d7ce" stroke="#e5d7ce" stroke-width="1.51"/><path d="M840,910L917,930L941,859Z" fill="#b7d1c2" stroke="#b7d1c2" stroke-width="1.51"/><path d="M1195,761L1184,688L1093,741Z" fill="#b1d0b7" stroke="#b1d0b7" stroke-width="1.51"/><path d="M754,1005L840,910L757,921Z" fill="#bdd4c9" stroke="#bdd4c9" stroke-width="1.51"/><path d="M1090,57L1016,-5L1020,81Z" fill="#d2d5c4" stroke="#d2d5c4" stroke-width="1.51"/><path d="M864,-125L772,-110L857,-32Z" fill="#e1d2c9" stroke="#e1d2c9" stroke-width="1.51"/><path d="M1114,139L1090,57L1020,81Z" fill="#cfd8c6" stroke="#cfd8c6" stroke-width="1.51"/><path d="M1184,73L1090,57L1114,139Z" fill="#cbd5c2" stroke="#cbd5c2" stroke-width="1.51"/><path d="M1293,477L1185,403L1187,514Z" fill="#ccded2" stroke="#ccded2" stroke-width="1.51"/><path d="M1093,741L1184,688L1114,656Z" fill="#b7d3bc" stroke="#b7d3bc" stroke-width="1.51"/><path d="M1110,818L1021,771L1003,828Z" fill="#b3d0bb" stroke="#b3d0bb" stroke-width="1.51"/><path d="M666,-95L591,-101L661,-27Z" fill="#e9d7cf" stroke="#e9d7cf" stroke-width="1.51"/><path d="M864,-125L857,-32L932,-133Z" fill="#ddcfc4" stroke="#ddcfc4" stroke-width="1.51"/><path d="M666,-95L772,-110L591,-101Z" fill="#e9d5ce" stroke="#e9d5ce" stroke-width="1.51"/><path d="M232,341L147,423L239,415Z" fill="#ebe6dd" stroke="#ebe6dd" stroke-width="1.51"/><path d="M241,587L157,669L265,661Z" fill="#d6ddcd" stroke="#d6ddcd" stroke-width="1.51"/><path d="M141,302L147,423L232,341Z" fill="#eae3d9" stroke="#eae3d9" stroke-width="1.51"/><path d="M165,251L232,341L247,249Z" fill="#e7e3d8" stroke="#e7e3d8" stroke-width="1.51"/><path d="M136,501L164,575L252,484Z" fill="#e4e1d7" stroke="#e4e1d7" stroke-width="1.51"/><path d="M265,661L243,762L319,736Z" fill="#cedac8" stroke="#cedac8" stroke-width="1.51"/><path d="M319,736L311,858L426,848Z" fill="#c6d7c6" stroke="#c6d7c6" stroke-width="1.51"/><path d="M492,-108L420,-42L496,-7Z" fill="#e6d6cb" stroke="#e6d6cb" stroke-width="1.51"/><path d="M1213,315L1208,214L1121,236Z" fill="#cbdbca" stroke="#cbdbca" stroke-width="1.51"/><path d="M687,939L754,1005L757,921Z" fill="#c0d6cd" stroke="#c0d6cd" stroke-width="1.51"/><path d="M606,1030L683,991L598,906Z" fill="#bed3c9" stroke="#bed3c9" stroke-width="1.51"/><path d="M1043,903L1110,818L1003,828Z" fill="#afcdb9" stroke="#afcdb9" stroke-width="1.51"/><path d="M170,746L243,762L265,661Z" fill="#cdd7c5" stroke="#cdd7c5" stroke-width="1.51"/><path d="M1208,214L1190,163L1121,236Z" fill="#cadac8" stroke="#cadac8" stroke-width="1.51"/><path d="M225,125L353,127L336,72Z" fill="#e4e1d3" stroke="#e4e1d3" stroke-width="1.51"/><path d="M225,125L165,251L247,249Z" fill="#e5e1d4" stroke="#e5e1d4" stroke-width="1.51"/><path d="M86,589L136,501L62,472Z" fill="#e3dcd2" stroke="#e3dcd2" stroke-width="1.51"/><path d="M147,423L136,501L252,484Z" fill="#eae4dd" stroke="#eae4dd" stroke-width="1.51"/><path d="M255,61L225,125L336,72Z" fill="#e4ddcf" stroke="#e4ddcf" stroke-width="1.51"/><path d="M683,991L754,1005L687,939Z" fill="#c0d5cc" stroke="#c0d5cc" stroke-width="1.51"/><path d="M516,1028L606,1030L598,906Z" fill="#bcd1c7" stroke="#bcd1c7" stroke-width="1.51"/><path d="M243,762L311,858L319,736Z" fill="#c7d7c4" stroke="#c7d7c4" stroke-width="1.51"/><path d="M1293,477L1187,514L1292,592Z" fill="#c3d8ca" stroke="#c3d8ca" stroke-width="1.51"/><path d="M1213,315L1302,245L1208,214Z" fill="#c7d9c9" stroke="#c7d9c9" stroke-width="1.51"/><path d="M165,251L141,302L232,341Z" fill="#e8e1d7" stroke="#e8e1d7" stroke-width="1.51"/><path d="M420,-42L343,-19L425,40Z" fill="#e5d8cd" stroke="#e5d8cd" stroke-width="1.51"/><path d="M412,-112L343,-19L420,-42Z" fill="#e4d4c8" stroke="#e4d4c8" stroke-width="1.51"/><path d="M1014,1027L1043,903L917,930Z" fill="#accaba" stroke="#accaba" stroke-width="1.51"/><path d="M917,930L1043,903L941,859Z" fill="#b2cebd" stroke="#b2cebd" stroke-width="1.51"/><path d="M311,858L400,933L426,848Z" fill="#c1d4c4" stroke="#c1d4c4" stroke-width="1.51"/><path d="M343,-19L255,61L336,72Z" fill="#e4dbcd" stroke="#e4dbcd" stroke-width="1.51"/><path d="M225,125L146,150L165,251Z" fill="#e5ded1" stroke="#e5ded1" stroke-width="1.51"/><path d="M591,-101L492,-108L577,-27Z" fill="#e7d5cc" stroke="#e7d5cc" stroke-width="1.51"/><path d="M772,-110L492,-108L591,-101Z" fill="#e8d4cc" stroke="#e8d4cc" stroke-width="1.51"/><path d="M932,-133L857,-32L953,-21Z" fill="#dbd1c5" stroke="#dbd1c5" stroke-width="1.51"/><path d="M772,-110L864,-125L492,-108Z" fill="#ead5ce" stroke="#ead5ce" stroke-width="1.51"/><path d="M243,762L256,859L311,858Z" fill="#c4d4c2" stroke="#c4d4c2" stroke-width="1.51"/><path d="M164,575L157,669L241,587Z" fill="#dadcce" stroke="#dadcce" stroke-width="1.51"/><path d="M86,589L157,669L164,575Z" fill="#d9d9cb" stroke="#d9d9cb" stroke-width="1.51"/><path d="M1200,836L1195,761L1110,818Z" fill="#aacab3" stroke="#aacab3" stroke-width="1.51"/><path d="M1110,818L1195,761L1093,741Z" fill="#afceb6" stroke="#afceb6" stroke-width="1.51"/><path d="M1292,592L1187,514L1184,577Z" fill="#c1d8c7" stroke="#c1d8c7" stroke-width="1.51"/><path d="M1292,592L1184,577L1287,654Z" fill="#b9d3c1" stroke="#b9d3c1" stroke-width="1.51"/><path d="M516,1028L598,906L481,907Z" fill="#bfd3c7" stroke="#bfd3c7" stroke-width="1.51"/><path d="M683,991L606,1030L754,1005Z" fill="#bcd2ca" stroke="#bcd2ca" stroke-width="1.51"/><path d="M754,1005L870,1015L840,910Z" fill="#b7d0c6" stroke="#b7d0c6" stroke-width="1.51"/><path d="M606,1030L870,1015L754,1005Z" fill="#bad2ca" stroke="#bad2ca" stroke-width="1.51"/><path d="M1022,-108L932,-133L953,-21Z" fill="#d7cdbf" stroke="#d7cdbf" stroke-width="1.51"/><path d="M1090,57L1087,-29L1016,-5Z" fill="#d0d1c0" stroke="#d0d1c0" stroke-width="1.51"/><path d="M1190,163L1184,73L1114,139Z" fill="#c9d7c3" stroke="#c9d7c3" stroke-width="1.51"/><path d="M1259,129L1184,73L1190,163Z" fill="#c6d4c2" stroke="#c6d4c2" stroke-width="1.51"/><path d="M1259,129L1190,163L1208,214Z" fill="#c6d7c5" stroke="#c6d7c5" stroke-width="1.51"/><path d="M1191,-40L1087,-29L1090,57Z" fill="#cbcdba" stroke="#cbcdba" stroke-width="1.51"/><path d="M1273,400L1301,336L1213,315Z" fill="#c9dacd" stroke="#c9dacd" stroke-width="1.51"/><path d="M1367,403L1301,336L1273,400Z" fill="#c7d9cd" stroke="#c7d9cd" stroke-width="1.51"/><path d="M1287,654L1184,577L1184,688Z" fill="#b8d3bf" stroke="#b8d3bf" stroke-width="1.51"/><path d="M1293,477L1367,403L1273,400Z" fill="#c8dbd0" stroke="#c8dbd0" stroke-width="1.51"/><path d="M178,821L256,859L243,762Z" fill="#c4d2bf" stroke="#c4d2bf" stroke-width="1.51"/><path d="M311,858L334,941L400,933Z" fill="#bed1c2" stroke="#bed1c2" stroke-width="1.51"/><path d="M157,669L170,746L265,661Z" fill="#d0d8c6" stroke="#d0d8c6" stroke-width="1.51"/><path d="M348,-127L412,-112L492,-108Z" fill="#e4cfc4" stroke="#e4cfc4" stroke-width="1.51"/><path d="M1022,-108L953,-21L1016,-5Z" fill="#d5cfc0" stroke="#d5cfc0" stroke-width="1.51"/><path d="M238,-42L163,72L255,61Z" fill="#e3d5c8" stroke="#e3d5c8" stroke-width="1.51"/><path d="M492,-108L412,-112L420,-42Z" fill="#e5d2c7" stroke="#e5d2c7" stroke-width="1.51"/><path d="M348,-127L492,-108L864,-125Z" fill="#e7d2c9" stroke="#e7d2c9" stroke-width="1.51"/><path d="M418,1011L481,907L400,933Z" fill="#bcd1c4" stroke="#bcd1c4" stroke-width="1.51"/><path d="M418,1011L516,1028L481,907Z" fill="#bad0c4" stroke="#bad0c4" stroke-width="1.51"/><path d="M260,932L334,941L311,858Z" fill="#bed1c1" stroke="#bed1c1" stroke-width="1.51"/><path d="M163,72L146,150L225,125Z" fill="#e4dacc" stroke="#e4dacc" stroke-width="1.51"/><path d="M63,247L75,313L141,302Z" fill="#e7dcd1" stroke="#e7dcd1" stroke-width="1.51"/><path d="M163,72L225,125L255,61Z" fill="#e3dacc" stroke="#e3dacc" stroke-width="1.51"/><path d="M1283,769L1287,654L1184,688Z" fill="#afcdb7" stroke="#afcdb7" stroke-width="1.51"/><path d="M1301,336L1302,245L1213,315Z" fill="#c7d9ca" stroke="#c7d9ca" stroke-width="1.51"/><path d="M1119,-104L1022,-108L1087,-29Z" fill="#cec9b7" stroke="#cec9b7" stroke-width="1.51"/><path d="M1087,-29L1022,-108L1016,-5Z" fill="#d1cdbd" stroke="#d1cdbd" stroke-width="1.51"/><path d="M136,501L86,589L164,575Z" fill="#e0dbd0" stroke="#e0dbd0" stroke-width="1.51"/><path d="M157,669L65,684L170,746Z" fill="#cfd3c1" stroke="#cfd3c1" stroke-width="1.51"/><path d="M62,472L136,501L147,423Z" fill="#eae1d9" stroke="#eae1d9" stroke-width="1.51"/><path d="M75,313L147,423L141,302Z" fill="#e9dfd6" stroke="#e9dfd6" stroke-width="1.51"/><path d="M1195,761L1283,769L1184,688Z" fill="#acccb5" stroke="#acccb5" stroke-width="1.51"/><path d="M1043,903L1124,911L1110,818Z" fill="#aac9b5" stroke="#aac9b5" stroke-width="1.51"/><path d="M1124,989L1124,911L1043,903Z" fill="#a5c6b3" stroke="#a5c6b3" stroke-width="1.51"/><path d="M63,247L141,302L165,251Z" fill="#e7ddd2" stroke="#e7ddd2" stroke-width="1.51"/><path d="M1191,-40L1119,-104L1087,-29Z" fill="#cac8b5" stroke="#cac8b5" stroke-width="1.51"/><path d="M1302,245L1259,129L1208,214Z" fill="#c5d7c5" stroke="#c5d7c5" stroke-width="1.51"/><path d="M60,406L62,472L147,423Z" fill="#ebe0d8" stroke="#ebe0d8" stroke-width="1.51"/><path d="M334,941L418,1011L400,933Z" fill="#bbcfc2" stroke="#bbcfc2" stroke-width="1.51"/><path d="M1124,911L1200,836L1110,818Z" fill="#a7c9b2" stroke="#a7c9b2" stroke-width="1.51"/><path d="M870,1015L950,1026L917,930Z" fill="#afccbe" stroke="#afccbe" stroke-width="1.51"/><path d="M606,1030L950,1026L870,1015Z" fill="#b5cec5" stroke="#b5cec5" stroke-width="1.51"/><path d="M75,313L60,406L147,423Z" fill="#e9ded6" stroke="#e9ded6" stroke-width="1.51"/><path d="M53,774L178,821L170,746Z" fill="#c7cebb" stroke="#c7cebb" stroke-width="1.51"/><path d="M170,746L178,821L243,762Z" fill="#c7d2bf" stroke="#c7d2bf" stroke-width="1.51"/><path d="M256,859L260,932L311,858Z" fill="#bfd1c0" stroke="#bfd1c0" stroke-width="1.51"/><path d="M334,941L312,1020L418,1011Z" fill="#b8cdc0" stroke="#b8cdc0" stroke-width="1.51"/><path d="M146,150L63,247L165,251Z" fill="#e5dccf" stroke="#e5dccf" stroke-width="1.51"/><path d="M238,-42L255,61L343,-19Z" fill="#e3d6c9" stroke="#e3d6c9" stroke-width="1.51"/><path d="M147,938L260,932L256,859Z" fill="#bdcdbc" stroke="#bdcdbc" stroke-width="1.51"/><path d="M412,-112L348,-127L343,-19Z" fill="#e3d1c5" stroke="#e3d1c5" stroke-width="1.51"/><path d="M932,-133L348,-127L864,-125Z" fill="#ead4cd" stroke="#ead4cd" stroke-width="1.51"/><path d="M-12,302L-6,427L60,406Z" fill="#e9d9d1" stroke="#e9d9d1" stroke-width="1.51"/><path d="M76,150L63,247L146,150Z" fill="#e4d9cc" stroke="#e4d9cc" stroke-width="1.51"/><path d="M249,-116L238,-42L343,-19Z" fill="#e2d1c4" stroke="#e2d1c4" stroke-width="1.51"/><path d="M1345,669L1350,584L1292,592Z" fill="#b5d0bf" stroke="#b5d0bf" stroke-width="1.51"/><path d="M1292,592L1350,584L1293,477Z" fill="#bdd5c6" stroke="#bdd5c6" stroke-width="1.51"/><path d="M1301,336L1367,325L1302,245Z" fill="#c4d7c9" stroke="#c4d7c9" stroke-width="1.51"/><path d="M1345,669L1292,592L1287,654Z" fill="#b3cfbd" stroke="#b3cfbd" stroke-width="1.51"/><path d="M1382,498L1367,403L1293,477Z" fill="#c6d9cf" stroke="#c6d9cf" stroke-width="1.51"/><path d="M1389,78L1261,67L1259,129Z" fill="#c1cebd" stroke="#c1cebd" stroke-width="1.51"/><path d="M950,1026L1014,1027L917,930Z" fill="#abc9bb" stroke="#abc9bb" stroke-width="1.51"/><path d="M1280,824L1195,761L1200,836Z" fill="#a7c8b1" stroke="#a7c8b1" stroke-width="1.51"/><path d="M606,1030L1014,1027L950,1026Z" fill="#b0ccc1" stroke="#b0ccc1" stroke-width="1.51"/><path d="M1280,824L1283,769L1195,761Z" fill="#a7c8b1" stroke="#a7c8b1" stroke-width="1.51"/><path d="M1187,933L1200,836L1124,911Z" fill="#a3c6b0" stroke="#a3c6b0" stroke-width="1.51"/><path d="M1259,129L1261,67L1184,73Z" fill="#c5d1be" stroke="#c5d1be" stroke-width="1.51"/><path d="M1354,168L1259,129L1302,245Z" fill="#c2d4c3" stroke="#c2d4c3" stroke-width="1.51"/><path d="M1367,403L1367,325L1301,336Z" fill="#c4d7cb" stroke="#c4d7cb" stroke-width="1.51"/><path d="M265,1033L312,1020L260,932Z" fill="#b6cbbd" stroke="#b6cbbd" stroke-width="1.51"/><path d="M86,589L65,684L157,669Z" fill="#d5d5c5" stroke="#d5d5c5" stroke-width="1.51"/><path d="M4,663L65,684L86,589Z" fill="#d5d2c2" stroke="#d5d2c2" stroke-width="1.51"/><path d="M-23,562L86,589L62,472Z" fill="#e1d7cd" stroke="#e1d7cd" stroke-width="1.51"/><path d="M1191,-40L1090,57L1184,73Z" fill="#c9cfbb" stroke="#c9cfbb" stroke-width="1.51"/><path d="M1022,-108L1119,-104L932,-133Z" fill="#d2c8b9" stroke="#d2c8b9" stroke-width="1.51"/><path d="M1261,67L1191,-40L1184,73Z" fill="#c6cdba" stroke="#c6cdba" stroke-width="1.51"/><path d="M1367,403L1456,317L1367,325Z" fill="#c1d5ca" stroke="#c1d5ca" stroke-width="1.51"/><path d="M1350,584L1382,498L1293,477Z" fill="#bfd5c9" stroke="#bfd5c9" stroke-width="1.51"/><path d="M1433,478L1382,498L1450,600Z" fill="#bad2c6" stroke="#bad2c6" stroke-width="1.51"/><path d="M1283,769L1345,669L1287,654Z" fill="#adcbb7" stroke="#adcbb7" stroke-width="1.51"/><path d="M163,72L76,150L146,150Z" fill="#e3d8ca" stroke="#e3d8ca" stroke-width="1.51"/><path d="M81,44L76,150L163,72Z" fill="#e3d4c6" stroke="#e3d4c6" stroke-width="1.51"/><path d="M1367,325L1367,247L1302,245Z" fill="#c2d5c7" stroke="#c2d5c7" stroke-width="1.51"/><path d="M1456,317L1367,247L1367,325Z" fill="#bfd3c7" stroke="#bfd3c7" stroke-width="1.51"/><path d="M1283,769L1382,751L1345,669Z" fill="#a7c8b3" stroke="#a7c8b3" stroke-width="1.51"/><path d="M1124,989L1187,933L1124,911Z" fill="#a0c4af" stroke="#a0c4af" stroke-width="1.51"/><path d="M1014,1027L1124,989L1043,903Z" fill="#a5c6b4" stroke="#a5c6b4" stroke-width="1.51"/><path d="M1299,904L1280,824L1200,836Z" fill="#a1c4af" stroke="#a1c4af" stroke-width="1.51"/><path d="M260,932L312,1020L334,941Z" fill="#b9cebf" stroke="#b9cebf" stroke-width="1.51"/><path d="M418,1011L312,1020L516,1028Z" fill="#b7cdc1" stroke="#b7cdc1" stroke-width="1.51"/><path d="M265,1033L260,932L177,999Z" fill="#b6c9ba" stroke="#b6c9ba" stroke-width="1.51"/><path d="M60,406L-6,427L62,472Z" fill="#eadcd5" stroke="#eadcd5" stroke-width="1.51"/><path d="M-12,302L60,406L75,313Z" fill="#e8dad1" stroke="#e8dad1" stroke-width="1.51"/><path d="M-12,302L75,313L63,247Z" fill="#e6d9ce" stroke="#e6d9ce" stroke-width="1.51"/><path d="M1367,247L1354,168L1302,245Z" fill="#c1d3c4" stroke="#c1d3c4" stroke-width="1.51"/><path d="M1261,67L1273,-26L1191,-40Z" fill="#c4c9b7" stroke="#c4c9b7" stroke-width="1.51"/><path d="M238,-42L175,-47L163,72Z" fill="#e2d0c3" stroke="#e2d0c3" stroke-width="1.51"/><path d="M348,-127L249,-116L343,-19Z" fill="#e2d0c3" stroke="#e2d0c3" stroke-width="1.51"/><path d="M249,-116L175,-47L238,-42Z" fill="#e1cdbf" stroke="#e1cdbf" stroke-width="1.51"/><path d="M-6,427L-23,477L62,472Z" fill="#eadbd4" stroke="#eadbd4" stroke-width="1.51"/><path d="M-94,493L-23,477L-6,427Z" fill="#e9d7d0" stroke="#e9d7d0" stroke-width="1.51"/><path d="M-18,229L-12,302L63,247Z" fill="#e5d6cb" stroke="#e5d6cb" stroke-width="1.51"/><path d="M-18,229L63,247L76,150Z" fill="#e4d6ca" stroke="#e4d6ca" stroke-width="1.51"/><path d="M65,684L53,774L170,746Z" fill="#cbcebc" stroke="#cbcebc" stroke-width="1.51"/><path d="M7,751L53,774L65,684Z" fill="#cacbb8" stroke="#cacbb8" stroke-width="1.51"/><path d="M-23,562L4,663L86,589Z" fill="#d9d2c5" stroke="#d9d2c5" stroke-width="1.51"/><path d="M175,-47L81,44L163,72Z" fill="#e2d0c2" stroke="#e2d0c2" stroke-width="1.51"/><path d="M-23,477L-23,562L62,472Z" fill="#e5d7ce" stroke="#e5d7ce" stroke-width="1.51"/><path d="M1382,498L1433,478L1367,403Z" fill="#c3d7ce" stroke="#c3d7ce" stroke-width="1.51"/><path d="M1367,247L1469,163L1354,168Z" fill="#bdd1c2" stroke="#bdd1c2" stroke-width="1.51"/><path d="M1450,600L1382,498L1350,584Z" fill="#b7d1c3" stroke="#b7d1c3" stroke-width="1.51"/><path d="M1450,600L1350,584L1345,669Z" fill="#b1cdbd" stroke="#b1cdbd" stroke-width="1.51"/><path d="M1362,-22L1273,-26L1261,67Z" fill="#c0c7b6" stroke="#c0c7b6" stroke-width="1.51"/><path d="M1191,-40L1204,-112L1119,-104Z" fill="#c7c4b1" stroke="#c7c4b1" stroke-width="1.51"/><path d="M312,1020L265,1033L516,1028Z" fill="#b5cbbf" stroke="#b5cbbf" stroke-width="1.51"/><path d="M516,1028L265,1033L606,1030Z" fill="#b7cdc2" stroke="#b7cdc2" stroke-width="1.51"/><path d="M606,1030L1286,1032L1014,1027Z" fill="#a8c7b9" stroke="#a8c7b9" stroke-width="1.51"/><path d="M147,938L256,859L178,821Z" fill="#c0cdbc" stroke="#c0cdbc" stroke-width="1.51"/><path d="M1259,-90L1204,-112L1191,-40Z" fill="#c5c3b0" stroke="#c5c3b0" stroke-width="1.51"/><path d="M1119,-104L1204,-112L932,-133Z" fill="#cdc5b4" stroke="#cdc5b4" stroke-width="1.51"/><path d="M53,774L86,858L178,821Z" fill="#c4cbb8" stroke="#c4cbb8" stroke-width="1.51"/><path d="M64,907L86,858L-13,828Z" fill="#c0c4b3" stroke="#c0c4b3" stroke-width="1.51"/><path d="M86,858L147,938L178,821Z" fill="#c0c9b8" stroke="#c0c9b8" stroke-width="1.51"/><path d="M-12,302L-87,319L-6,427Z" fill="#e8d5cd" stroke="#e8d5cd" stroke-width="1.51"/><path d="M-31,141L-18,229L76,150Z" fill="#e3d3c7" stroke="#e3d3c7" stroke-width="1.51"/><path d="M1014,1027L1181,1013L1124,989Z" fill="#9ec2b0" stroke="#9ec2b0" stroke-width="1.51"/><path d="M1124,989L1181,1013L1187,933Z" fill="#9dc1ad" stroke="#9dc1ad" stroke-width="1.51"/><path d="M1187,933L1299,904L1200,836Z" fill="#a0c3ae" stroke="#a0c3ae" stroke-width="1.51"/><path d="M1353,843L1283,769L1280,824Z" fill="#a2c4af" stroke="#a2c4af" stroke-width="1.51"/><path d="M1353,843L1382,751L1283,769Z" fill="#a2c5af" stroke="#a2c5af" stroke-width="1.51"/><path d="M1286,1032L1299,904L1187,933Z" fill="#99bfab" stroke="#99bfab" stroke-width="1.51"/><path d="M-13,828L7,751L-96,769Z" fill="#c6c4b1" stroke="#c6c4b1" stroke-width="1.51"/><path d="M4,663L7,751L65,684Z" fill="#cecdbb" stroke="#cecdbb" stroke-width="1.51"/><path d="M1362,-22L1259,-90L1273,-26Z" fill="#c1c3b1" stroke="#c1c3b1" stroke-width="1.51"/><path d="M1476,404L1456,317L1367,403Z" fill="#c0d4ca" stroke="#c0d4ca" stroke-width="1.51"/><path d="M1461,680L1450,600L1345,669Z" fill="#accab9" stroke="#accab9" stroke-width="1.51"/><path d="M1433,478L1476,404L1367,403Z" fill="#c2d6cd" stroke="#c2d6cd" stroke-width="1.51"/><path d="M1273,-26L1259,-90L1191,-40Z" fill="#c4c4b2" stroke="#c4c4b2" stroke-width="1.51"/><path d="M1389,78L1259,129L1354,168Z" fill="#bfd0bf" stroke="#bfd0bf" stroke-width="1.51"/><path d="M147,938L177,999L260,932Z" fill="#b9c8b9" stroke="#b9c8b9" stroke-width="1.51"/><path d="M71,1004L177,999L147,938Z" fill="#b7c3b4" stroke="#b7c3b4" stroke-width="1.51"/><path d="M64,907L147,938L86,858Z" fill="#bdc6b5" stroke="#bdc6b5" stroke-width="1.51"/><path d="M1299,904L1353,843L1280,824Z" fill="#9ec2ae" stroke="#9ec2ae" stroke-width="1.51"/><path d="M1382,751L1461,680L1345,669Z" fill="#a7c8b5" stroke="#a7c8b5" stroke-width="1.51"/><path d="M1469,163L1389,78L1354,168Z" fill="#bccfbe" stroke="#bccfbe" stroke-width="1.51"/><path d="M1532,489L1476,404L1433,478Z" fill="#bed4cc" stroke="#bed4cc" stroke-width="1.51"/><path d="M-10,82L-31,141L76,150Z" fill="#e2d1c3" stroke="#e2d1c3" stroke-width="1.51"/><path d="M-18,229L-87,319L-12,302Z" fill="#e6d4c9" stroke="#e6d4c9" stroke-width="1.51"/><path d="M-23,477L-94,493L-23,562Z" fill="#e3d4cb" stroke="#e3d4cb" stroke-width="1.51"/><path d="M-10,82L76,150L81,44Z" fill="#e2d0c2" stroke="#e2d0c2" stroke-width="1.51"/><path d="M-10,82L81,44L-6,-8Z" fill="#e1cabc" stroke="#e1cabc" stroke-width="1.51"/><path d="M1456,317L1469,252L1367,247Z" fill="#bdd2c5" stroke="#bdd2c5" stroke-width="1.51"/><path d="M1534,335L1469,252L1456,317Z" fill="#bbd0c5" stroke="#bbd0c5" stroke-width="1.51"/><path d="M-13,828L86,858L53,774Z" fill="#c3c7b5" stroke="#c3c7b5" stroke-width="1.51"/><path d="M58,-47L81,44L175,-47Z" fill="#e1cabd" stroke="#e1cabd" stroke-width="1.51"/><path d="M-93,222L-87,319L-18,229Z" fill="#e5d1c6" stroke="#e5d1c6" stroke-width="1.51"/><path d="M-23,562L-120,600L4,663Z" fill="#d8cec0" stroke="#d8cec0" stroke-width="1.51"/><path d="M249,-116L139,-128L175,-47Z" fill="#e1c8bb" stroke="#e1c8bb" stroke-width="1.51"/><path d="M348,-127L139,-128L249,-116Z" fill="#e1c9bc" stroke="#e1c9bc" stroke-width="1.51"/><path d="M932,-133L139,-128L348,-127Z" fill="#e5cfc5" stroke="#e5cfc5" stroke-width="1.51"/><path d="M-114,385L-94,493L-6,427Z" fill="#ead6ce" stroke="#ead6ce" stroke-width="1.51"/><path d="M7,751L-13,828L53,774Z" fill="#c6c7b4" stroke="#c6c7b4" stroke-width="1.51"/><path d="M1450,600L1532,489L1433,478Z" fill="#b7d0c5" stroke="#b7d0c5" stroke-width="1.51"/><path d="M1469,733L1461,680L1382,751Z" fill="#a3c5b1" stroke="#a3c5b1" stroke-width="1.51"/><path d="M94,-100L58,-47L175,-47Z" fill="#e1c6b9" stroke="#e1c6b9" stroke-width="1.51"/><path d="M1450,46L1362,-22L1389,78Z" fill="#bbc6b6" stroke="#bbc6b6" stroke-width="1.51"/><path d="M1389,78L1362,-22L1261,67Z" fill="#bfc9b8" stroke="#bfc9b8" stroke-width="1.51"/><path d="M139,-128L94,-100L175,-47Z" fill="#e0c5b8" stroke="#e0c5b8" stroke-width="1.51"/><path d="M-102,683L7,751L4,663Z" fill="#cec9b8" stroke="#cec9b8" stroke-width="1.51"/><path d="M-87,319L-114,385L-6,427Z" fill="#e8d4cc" stroke="#e8d4cc" stroke-width="1.51"/><path d="M-93,222L-114,385L-87,319Z" fill="#e6d1c7" stroke="#e6d1c7" stroke-width="1.51"/><path d="M1469,252L1469,163L1367,247Z" fill="#bcd0c2" stroke="#bcd0c2" stroke-width="1.51"/><path d="M1540,251L1469,163L1469,252Z" fill="#b8cec1" stroke="#b8cec1" stroke-width="1.51"/><path d="M-89,147L-31,141L-115,69Z" fill="#e1cbbe" stroke="#e1cbbe" stroke-width="1.51"/><path d="M-89,147L-93,222L-31,141Z" fill="#e2cec1" stroke="#e2cec1" stroke-width="1.51"/><path d="M-31,141L-93,222L-18,229Z" fill="#e3d1c4" stroke="#e3d1c4" stroke-width="1.51"/><path d="M1547,584L1532,489L1450,600Z" fill="#b1cdc1" stroke="#b1cdc1" stroke-width="1.51"/><path d="M1467,836L1469,733L1382,751Z" fill="#9ec2ae" stroke="#9ec2ae" stroke-width="1.51"/><path d="M1467,836L1382,751L1353,843Z" fill="#9dc1ad" stroke="#9dc1ad" stroke-width="1.51"/><path d="M1476,404L1534,335L1456,317Z" fill="#bcd1c7" stroke="#bcd1c7" stroke-width="1.51"/><path d="M1547,584L1450,600L1543,644Z" fill="#abc9bb" stroke="#abc9bb" stroke-width="1.51"/><path d="M58,-47L-6,-8L81,44Z" fill="#e1c7ba" stroke="#e1c7ba" stroke-width="1.51"/><path d="M0,-128L-6,-8L58,-47Z" fill="#e0c1b4" stroke="#e0c1b4" stroke-width="1.51"/><path d="M1299,904L1375,939L1353,843Z" fill="#99beac" stroke="#99beac" stroke-width="1.51"/><path d="M1181,1013L1286,1032L1187,933Z" fill="#98beab" stroke="#98beab" stroke-width="1.51"/><path d="M1014,1027L1286,1032L1181,1013Z" fill="#99beac" stroke="#99beac" stroke-width="1.51"/><path d="M265,1033L1286,1032L606,1030Z" fill="#bbd2cb" stroke="#bbd2cb" stroke-width="1.51"/><path d="M-94,493L-120,600L-23,562Z" fill="#dfcfc5" stroke="#dfcfc5" stroke-width="1.51"/><path d="M-114,385L-120,600L-94,493Z" fill="#e5d1c9" stroke="#e5d1c9" stroke-width="1.51"/><path d="M-120,600L-102,683L4,663Z" fill="#d4cabb" stroke="#d4cabb" stroke-width="1.51"/><path d="M1550,388L1534,335L1476,404Z" fill="#bbd1c8" stroke="#bbd1c8" stroke-width="1.51"/><path d="M-115,69L-31,141L-10,82Z" fill="#e1cabd" stroke="#e1cabd" stroke-width="1.51"/><path d="M-93,222L-115,69L-114,385Z" fill="#e3cdc2" stroke="#e3cdc2" stroke-width="1.51"/><path d="M1354,990L1375,939L1299,904Z" fill="#95bcaa" stroke="#95bcaa" stroke-width="1.51"/><path d="M1469,163L1450,46L1389,78Z" fill="#b9caba" stroke="#b9caba" stroke-width="1.51"/><path d="M1472,-5L1450,46L1521,65Z" fill="#b7c3b4" stroke="#b7c3b4" stroke-width="1.51"/><path d="M-19,934L64,907L-13,828Z" fill="#bec1b0" stroke="#bec1b0" stroke-width="1.51"/><path d="M-19,934L71,1004L64,907Z" fill="#b9bfb0" stroke="#b9bfb0" stroke-width="1.51"/><path d="M64,907L71,1004L147,938Z" fill="#b9c2b3" stroke="#b9c2b3" stroke-width="1.51"/><path d="M177,999L71,1004L265,1033Z" fill="#b5c4b6" stroke="#b5c4b6" stroke-width="1.51"/><path d="M1534,335L1540,251L1469,252Z" fill="#b8cec3" stroke="#b8cec3" stroke-width="1.51"/><path d="M1532,489L1550,388L1476,404Z" fill="#bcd3cb" stroke="#bcd3cb" stroke-width="1.51"/><path d="M1286,1032L1354,990L1299,904Z" fill="#94bcaa" stroke="#94bcaa" stroke-width="1.51"/><path d="M1543,644L1461,680L1532,755Z" fill="#a2c4b2" stroke="#a2c4b2" stroke-width="1.51"/><path d="M1543,644L1450,600L1461,680Z" fill="#a9c8b9" stroke="#a9c8b9" stroke-width="1.51"/><path d="M1532,489L1547,584L1550,388Z" fill="#b7d0c7" stroke="#b7d0c7" stroke-width="1.51"/><path d="M1467,836L1353,843L1457,919Z" fill="#97bdaa" stroke="#97bdaa" stroke-width="1.51"/><path d="M1550,388L1540,251L1534,335Z" fill="#b8cfc5" stroke="#b8cfc5" stroke-width="1.51"/><path d="M-102,683L-96,769L7,751Z" fill="#cac5b2" stroke="#cac5b2" stroke-width="1.51"/><path d="M-120,600L-96,769L-102,683Z" fill="#d0c6b5" stroke="#d0c6b5" stroke-width="1.51"/><path d="M1457,919L1353,843L1375,939Z" fill="#96bcab" stroke="#96bcab" stroke-width="1.51"/><path d="M1532,755L1461,680L1469,733Z" fill="#a0c3b0" stroke="#a0c3b0" stroke-width="1.51"/><path d="M1362,-22L1371,-117L1259,-90Z" fill="#bfbfae" stroke="#bfbfae" stroke-width="1.51"/><path d="M1259,-90L1371,-117L1204,-112Z" fill="#c1bead" stroke="#c1bead" stroke-width="1.51"/><path d="M1204,-112L1371,-117L932,-133Z" fill="#c7c1ae" stroke="#c7c1ae" stroke-width="1.51"/><path d="M1440,-114L1371,-117L1362,-22Z" fill="#bbbcac" stroke="#bbbcac" stroke-width="1.51"/><path d="M-88,822L-19,934L-13,828Z" fill="#c0bfae" stroke="#c0bfae" stroke-width="1.51"/><path d="M-96,769L-88,822L-13,828Z" fill="#c4c1ae" stroke="#c4c1ae" stroke-width="1.51"/><path d="M1521,65L1450,46L1469,163Z" fill="#b7c8b9" stroke="#b7c8b9" stroke-width="1.51"/><path d="M1450,46L1472,-5L1362,-22Z" fill="#bac2b3" stroke="#bac2b3" stroke-width="1.51"/><path d="M1467,836L1532,755L1469,733Z" fill="#9ac0ac" stroke="#9ac0ac" stroke-width="1.51"/><path d="M1530,858L1532,755L1467,836Z" fill="#96bdaa" stroke="#96bdaa" stroke-width="1.51"/><path d="M-85,-17L-115,69L-10,82Z" fill="#e0c5b8" stroke="#e0c5b8" stroke-width="1.51"/><path d="M-89,147L-115,69L-93,222Z" fill="#e1ccbf" stroke="#e1ccbf" stroke-width="1.51"/><path d="M-114,385L-115,69L-120,600Z" fill="#e7d0c8" stroke="#e7d0c8" stroke-width="1.51"/><path d="M-85,-17L-10,82L-6,-8Z" fill="#e0c4b8" stroke="#e0c4b8" stroke-width="1.51"/><path d="M1466,1010L1457,919L1375,939Z" fill="#8fb8a8" stroke="#8fb8a8" stroke-width="1.51"/><path d="M94,-100L0,-128L58,-47Z" fill="#dfc0b3" stroke="#dfc0b3" stroke-width="1.51"/><path d="M139,-128L0,-128L94,-100Z" fill="#dfbfb3" stroke="#dfbfb3" stroke-width="1.51"/><path d="M932,-133L0,-128L139,-128Z" fill="#e3cdc1" stroke="#e3cdc1" stroke-width="1.51"/><path d="M1556,125L1521,65L1469,163Z" fill="#b5c8ba" stroke="#b5c8ba" stroke-width="1.51"/><path d="M-80,-93L-85,-17L-6,-8Z" fill="#dfbeb1" stroke="#dfbeb1" stroke-width="1.51"/><path d="M1540,251L1556,125L1469,163Z" fill="#b6ccbe" stroke="#b6ccbe" stroke-width="1.51"/><path d="M1550,388L1556,125L1540,251Z" fill="#b6cdc2" stroke="#b6cdc2" stroke-width="1.51"/><path d="M1547,584L1556,125L1550,388Z" fill="#b9cfc7" stroke="#b9cfc7" stroke-width="1.51"/><path d="M-107,936L-24,996L-19,934Z" fill="#b8baab" stroke="#b8baab" stroke-width="1.51"/><path d="M-19,934L-24,996L71,1004Z" fill="#b7bcad" stroke="#b7bcad" stroke-width="1.51"/><path d="M71,1004L-106,993L265,1033Z" fill="#b4beb0" stroke="#b4beb0" stroke-width="1.51"/><path d="M1472,-5L1440,-114L1362,-22Z" fill="#babeae" stroke="#babeae" stroke-width="1.51"/><path d="M1536,-22L1440,-114L1472,-5Z" fill="#b7bcad" stroke="#b7bcad" stroke-width="1.51"/><path d="M1536,-22L1472,-5L1521,65Z" fill="#b5c0b1" stroke="#b5c0b1" stroke-width="1.51"/><path d="M1457,919L1530,858L1467,836Z" fill="#93bba9" stroke="#93bba9" stroke-width="1.51"/><path d="M1532,755L1530,858L1543,644Z" fill="#9abfac" stroke="#9abfac" stroke-width="1.51"/><path d="M1543,644L1531,1032L1547,584Z" fill="#99bfac" stroke="#99bfac" stroke-width="1.51"/><path d="M0,-128L-80,-93L-6,-8Z" fill="#dfbdb0" stroke="#dfbdb0" stroke-width="1.51"/><path d="M-85,-17L-80,-93L-115,69Z" fill="#dfbeb1" stroke="#dfbeb1" stroke-width="1.51"/><path d="M-88,822L-107,936L-19,934Z" fill="#bdbcab" stroke="#bdbcab" stroke-width="1.51"/><path d="M-96,769L-107,936L-88,822Z" fill="#c1bdab" stroke="#c1bdab" stroke-width="1.51"/><path d="M-120,600L-107,936L-96,769Z" fill="#c6c0ad" stroke="#c6c0ad" stroke-width="1.51"/><path d="M1556,125L1536,-22L1521,65Z" fill="#b3c2b4" stroke="#b3c2b4" stroke-width="1.51"/><path d="M1457,919L1530,924L1530,858Z" fill="#8fb8a8" stroke="#8fb8a8" stroke-width="1.51"/><path d="M1354,990L1466,1010L1375,939Z" fill="#8fb8a8" stroke="#8fb8a8" stroke-width="1.51"/><path d="M1286,1032L1466,1010L1354,990Z" fill="#8eb7a7" stroke="#8eb7a7" stroke-width="1.51"/><path d="M1466,1010L1530,924L1457,919Z" fill="#8cb6a7" stroke="#8cb6a7" stroke-width="1.51"/><path d="M1530,858L1530,924L1543,644Z" fill="#95bcaa" stroke="#95bcaa" stroke-width="1.51"/><path d="M-107,936L-106,993L-24,996Z" fill="#b7b7a8" stroke="#b7b7a8" stroke-width="1.51"/><path d="M-24,996L-106,993L71,1004Z" fill="#b5b9ab" stroke="#b5b9ab" stroke-width="1.51"/><path d="M-120,600L-106,993L-107,936Z" fill="#c1bcab" stroke="#c1bcab" stroke-width="1.51"/><path d="M1466,1010L1531,1032L1530,924Z" fill="#88b3a4" stroke="#88b3a4" stroke-width="1.51"/><path d="M1530,924L1531,1032L1543,644Z" fill="#91b9a8" stroke="#91b9a8" stroke-width="1.51"/><path d="M1286,1032L1531,1032L1466,1010Z" fill="#8ab4a5" stroke="#8ab4a5" stroke-width="1.51"/><path d="M265,1033L1531,1032L1286,1032Z" fill="#a3c4b4" stroke="#a3c4b4" stroke-width="1.51"/><path d="M1536,-22L1554,-129L1440,-114Z" fill="#b5b8a9" stroke="#b5b8a9" stroke-width="1.51"/><path d="M1440,-114L1554,-129L1371,-117Z" fill="#b7b7a8" stroke="#b7b7a8" stroke-width="1.51"/><path d="M1371,-117L1554,-129L932,-133Z" fill="#c1bdab" stroke="#c1bdab" stroke-width="1.51"/><path d="M1556,125L1554,-129L1536,-22Z" fill="#b3bdaf" stroke="#b3bdaf" stroke-width="1.51"/></svg>
\ No newline at end of file diff --git a/src/client/components/acct.vue b/src/client/components/acct.vue new file mode 100644 index 0000000000..250e8b2371 --- /dev/null +++ b/src/client/components/acct.vue @@ -0,0 +1,29 @@ +<template> +<span class="mk-acct" v-once> + <span class="name">@{{ user.username }}</span> + <span class="host" v-if="user.host || detail || $store.state.settings.showFullAcct">@{{ user.host || host }}</span> +</span> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { toUnicode } from 'punycode'; +import { host } from '../config'; + +export default Vue.extend({ + props: ['user', 'detail'], + data() { + return { + host: toUnicode(host), + }; + } +}); +</script> + +<style lang="scss" scoped> +.mk-acct { + > .host { + opacity: 0.5; + } +} +</style> diff --git a/src/client/app/common/views/components/autocomplete.vue b/src/client/components/autocomplete.vue index bbfb7896ae..232b25dd61 100644 --- a/src/client/app/common/views/components/autocomplete.vue +++ b/src/client/components/autocomplete.vue @@ -28,10 +28,10 @@ <script lang="ts"> import Vue from 'vue'; -import { emojilist } from '../../../../../misc/emojilist'; -import contains from '../../../common/scripts/contains'; -import { twemojiSvgBase } from '../../../../../misc/twemoji-base'; -import { getStaticImageUrl } from '../../../common/scripts/get-static-image-url'; +import { emojilist } from '../../misc/emojilist'; +import contains from '../scripts/contains'; +import { twemojiSvgBase } from '../../misc/twemoji-base'; +import { getStaticImageUrl } from '../scripts/get-static-image-url'; type EmojiDef = { emoji: string; @@ -73,42 +73,7 @@ for (const x of lib) { emjdb.sort((a, b) => a.name.length - b.name.length); export default Vue.extend({ - props: { - type: { - type: String, - required: true, - }, - - q: { - type: String, - required: true, - }, - - textarea: { - type: Object, - required: true, - }, - - complete: { - type: Function, - required: true, - }, - - close: { - type: Function, - required: true, - }, - - x: { - type: Number, - required: true, - }, - - y: { - type: Number, - required: true, - }, - }, + props: ['type', 'q', 'textarea', 'complete', 'close', 'x', 'y'], data() { return { @@ -184,7 +149,7 @@ export default Vue.extend({ this.textarea.addEventListener('keydown', this.onKeydown); - for (const el of Array.from(document.querySelectorAll('body *'))) { + for (const el of Array.from(document.querySelectorAll('*'))) { el.addEventListener('mousedown', this.onMousedown); } @@ -202,7 +167,7 @@ export default Vue.extend({ beforeDestroy() { this.textarea.removeEventListener('keydown', this.onKeydown); - for (const el of Array.from(document.querySelectorAll('body *'))) { + for (const el of Array.from(document.querySelectorAll('*'))) { el.removeEventListener('mousedown', this.onMousedown); } }, @@ -363,96 +328,116 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.mk-autocomplete - position fixed - z-index 65535 - max-width 100% - margin-top calc(1em + 8px) - overflow hidden - background var(--faceHeader) - border solid 1px rgba(#000, 0.1) - border-radius 4px - transition top 0.1s ease, left 0.1s ease +<style lang="scss" scoped> +.mk-autocomplete { + position: fixed; + z-index: 65535; + max-width: 100%; + margin-top: calc(1em + 8px); + overflow: hidden; + background: var(--panel); + border: solid 1px rgba(#000, 0.1); + border-radius: 4px; + transition: top 0.1s ease, left 0.1s ease; - > ol - display block - margin 0 - padding 4px 0 - max-height 190px - max-width 500px - overflow auto - list-style none + > ol { + display: block; + margin: 0; + padding: 4px 0; + max-height: 190px; + max-width: 500px; + overflow: auto; + list-style: none; - > li - display flex - align-items center - padding 4px 12px - white-space nowrap - overflow hidden - font-size 0.9em - color rgba(#000, 0.8) - cursor default + > li { + display: flex; + align-items: center; + padding: 4px 12px; + white-space: nowrap; + overflow: hidden; + font-size: 0.9em; + cursor: default; - &, * - user-select none + &, * { + user-select: none; + } - * - overflow hidden - text-overflow ellipsis + * { + overflow: hidden; + text-overflow: ellipsis; + } - &:hover - background var(--autocompleteItemHoverBg) + &:hover { + background: var(--yrnqrguo); + } - &[data-selected='true'] - background var(--primary) + &[data-selected='true'] { + background: var(--accent); - &, * - color #fff !important + &, * { + color: #fff !important; + } + } - &:active - background var(--primaryDarken10) + &:active { + background: var(--accentDarken); - &, * - color #fff !important + &, * { + color: #fff !important; + } + } + } + } - > .users > li + > .users > li { - .avatar - min-width 28px - min-height 28px - max-width 28px - max-height 28px - margin 0 8px 0 0 - border-radius 100% + .avatar { + min-width: 28px; + min-height: 28px; + max-width: 28px; + max-height: 28px; + margin: 0 8px 0 0; + border-radius: 100%; + } - .name - margin 0 8px 0 0 - color var(--autocompleteItemText) + .name { + margin: 0 8px 0 0; + color: var(--autocompleteItemText); + } - .username - color var(--autocompleteItemTextSub) + .username { + color: var(--autocompleteItemTextSub); + } + } - > .hashtags > li + > .hashtags > li { - .name - color var(--autocompleteItemText) + .name { + color: var(--autocompleteItemText); + } + } - > .emojis > li + > .emojis > li { - .emoji - display inline-block - margin 0 4px 0 0 - width 24px + .emoji { + display: inline-block; + margin: 0 4px 0 0; + width: 24px; - > img - width 24px - vertical-align bottom + > img { + width: 24px; + vertical-align: bottom; + } + } - .name - color var(--autocompleteItemText) + .name { + color: var(--autocompleteItemText); + } - .alias - margin 0 0 0 8px - color var(--autocompleteItemTextSub) + .alias { + margin: 0 0 0 8px; + color: var(--autocompleteItemTextSub); + } + } +} </style> diff --git a/src/client/components/avatar.vue b/src/client/components/avatar.vue new file mode 100644 index 0000000000..12cbb82478 --- /dev/null +++ b/src/client/components/avatar.vue @@ -0,0 +1,116 @@ +<template> +<span class="mk-avatar" :class="{ cat }" :title="user | acct" v-if="disableLink && !disablePreview" v-user-preview="user.id" @click="onClick"> + <span class="inner" :style="icon"></span> +</span> +<span class="mk-avatar" :class="{ cat }" :title="user | acct" v-else-if="disableLink && disablePreview" @click="onClick"> + <span class="inner" :style="icon"></span> +</span> +<router-link class="mk-avatar" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && !disablePreview" v-user-preview="user.id"> + <span class="inner" :style="icon"></span> +</router-link> +<router-link class="mk-avatar" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && disablePreview"> + <span class="inner" :style="icon"></span> +</router-link> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { getStaticImageUrl } from '../scripts/get-static-image-url'; + +export default Vue.extend({ + props: { + user: { + type: Object, + required: true + }, + target: { + required: false, + default: null + }, + disableLink: { + required: false, + default: false + }, + disablePreview: { + required: false, + default: false + } + }, + computed: { + cat(): boolean { + return this.user.isCat; + }, + url(): string { + return this.$store.state.device.disableShowingAnimatedImages + ? getStaticImageUrl(this.user.avatarUrl) + : this.user.avatarUrl; + }, + icon(): any { + return { + backgroundColor: this.user.avatarColor, + backgroundImage: `url(${this.url})`, + }; + } + }, + watch: { + 'user.avatarColor'() { + this.$el.style.color = this.user.avatarColor; + } + }, + mounted() { + if (this.user.avatarColor) { + this.$el.style.color = this.user.avatarColor; + } + }, + methods: { + onClick(e) { + this.$emit('click', e); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-avatar { + position: relative; + display: inline-block; + vertical-align: bottom; + flex-shrink: 0; + border-radius: 100%; + line-height: 16px; + + &.cat { + &:before, &:after { + background: #df548f; + border: solid 4px currentColor; + box-sizing: border-box; + content: ''; + display: inline-block; + height: 50%; + width: 50%; + } + + &:before { + border-radius: 0 75% 75%; + transform: rotate(37.5deg) skew(30deg); + } + + &:after { + border-radius: 75% 0 75% 75%; + transform: rotate(-37.5deg) skew(-30deg); + } + } + + .inner { + background-position: center center; + background-size: cover; + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; + border-radius: 100%; + z-index: 1; + } +} +</style> diff --git a/src/client/app/common/views/components/avatars.vue b/src/client/components/avatars.vue index 0dc1ece3bf..0dc1ece3bf 100644 --- a/src/client/app/common/views/components/avatars.vue +++ b/src/client/components/avatars.vue diff --git a/src/client/app/common/views/components/code-core.vue b/src/client/components/code-core.vue index 219ed1d80a..a9253528d9 100644 --- a/src/client/app/common/views/components/code-core.vue +++ b/src/client/components/code-core.vue @@ -7,7 +7,6 @@ import Vue from 'vue'; import 'prismjs'; import 'prismjs/themes/prism-okaidia.css'; import XPrism from 'vue-prism-component'; - export default Vue.extend({ components: { XPrism @@ -26,7 +25,6 @@ export default Vue.extend({ required: false } }, - computed: { prismLang() { return Prism.languages[this.lang] ? this.lang : 'js'; diff --git a/src/client/app/common/views/components/code.vue b/src/client/components/code.vue index d52c9f7bc2..94cad57be4 100644 --- a/src/client/app/common/views/components/code.vue +++ b/src/client/components/code.vue @@ -4,12 +4,10 @@ <script lang="ts"> import Vue from 'vue'; - export default Vue.extend({ components: { XCode: () => import('./code-core.vue').then(m => m.default) }, - props: { code: { type: String, diff --git a/src/client/components/cw-button.vue b/src/client/components/cw-button.vue new file mode 100644 index 0000000000..4516e5210c --- /dev/null +++ b/src/client/components/cw-button.vue @@ -0,0 +1,73 @@ +<template> +<button class="nrvgflfuaxwgkxoynpnumyookecqrrvh _button" @click="toggle"> + <b>{{ value ? this.$t('_cw.hide') : this.$t('_cw.show') }}</b> + <span v-if="!value">{{ this.label }}</span> +</button> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import { length } from 'stringz'; +import { concat } from '../../prelude/array'; + +export default Vue.extend({ + i18n, + + props: { + value: { + type: Boolean, + required: true + }, + note: { + type: Object, + required: true + } + }, + + computed: { + label(): string { + return concat([ + this.note.text ? [this.$t('_cw.chars', { count: length(this.note.text) })] : [], + this.note.files && this.note.files.length !== 0 ? [this.$t('_cw.files', { count: this.note.files.length }) ] : [], + this.note.poll != null ? [this.$t('_cw.poll')] : [] + ] as string[][]).join(' / '); + } + }, + + methods: { + length, + + toggle() { + this.$emit('input', !this.value); + } + } +}); +</script> + +<style lang="scss" scoped> +.nrvgflfuaxwgkxoynpnumyookecqrrvh { + display: inline-block; + padding: 4px 8px; + font-size: 0.7em; + color: var(--cwFg); + background: var(--cwBg); + border-radius: 2px; + + &:hover { + background: var(--cwHoverBg); + } + + > span { + margin-left: 4px; + + &:before { + content: '('; + } + + &:after { + content: ')'; + } + } +} +</style> diff --git a/src/client/components/date-separated-list.vue b/src/client/components/date-separated-list.vue new file mode 100644 index 0000000000..00c3cd6643 --- /dev/null +++ b/src/client/components/date-separated-list.vue @@ -0,0 +1,94 @@ +<template> +<sequential-entrance class="sqadhkmv" ref="list" :direction="direction"> + <template v-for="(item, i) in items"> + <slot :item="item" :i="i"></slot> + <div class="separator" :key="item.id + '_date'" :data-index="i" v-if="i != items.length - 1 && new Date(item.createdAt).getDate() != new Date(items[i + 1].createdAt).getDate()"> + <p class="date"> + <span><fa class="icon" :icon="faAngleUp"/>{{ getDateText(item.createdAt) }}</span> + <span>{{ getDateText(items[i + 1].createdAt) }}<fa class="icon" :icon="faAngleDown"/></span> + </p> + </div> + </template> +</sequential-entrance> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; + +export default Vue.extend({ + i18n, + + props: { + items: { + type: Array, + required: true, + }, + direction: { + type: String, + required: false + } + }, + + data() { + return { + faAngleUp, faAngleDown + }; + }, + + methods: { + getDateText(time: string) { + const date = new Date(time).getDate(); + const month = new Date(time).getMonth() + 1; + return this.$t('monthAndDay', { + month: month.toString(), + day: date.toString() + }); + }, + + focus() { + this.$refs.list.focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.sqadhkmv { + > .separator { + text-align: center; + + > .date { + display: inline-block; + position: relative; + margin: 0; + padding: 0 16px; + line-height: 32px; + text-align: center; + font-size: 12px; + border-radius: 64px; + background: var(--dateLabelBg); + color: var(--dateLabelFg); + + > span { + &:first-child { + margin-right: 8px; + + > .icon { + margin-right: 8px; + } + } + + &:last-child { + margin-left: 8px; + + > .icon { + margin-left: 8px; + } + } + } + } + } +} +</style> diff --git a/src/client/components/dialog.vue b/src/client/components/dialog.vue new file mode 100644 index 0000000000..5311611575 --- /dev/null +++ b/src/client/components/dialog.vue @@ -0,0 +1,320 @@ +<template> +<div class="mk-dialog" :class="{ iconOnly }"> + <transition name="bg-fade" appear> + <div class="bg" ref="bg" @click="onBgClick" v-if="show"></div> + </transition> + <transition name="dialog" appear @after-leave="() => { destroyDom(); }"> + <div class="main" ref="main" v-if="show"> + <template v-if="type == 'signin'"> + <mk-signin/> + </template> + <template v-else> + <div class="icon" v-if="icon"> + <fa :icon="icon"/> + </div> + <div class="icon" v-else-if="!input && !select && !user" :class="type"> + <fa :icon="faCheck" v-if="type === 'success'"/> + <fa :icon="faTimesCircle" v-if="type === 'error'"/> + <fa :icon="faExclamationTriangle" v-if="type === 'warning'"/> + <fa :icon="faInfoCircle" v-if="type === 'info'"/> + <fa :icon="faQuestionCircle" v-if="type === 'question'"/> + <fa :icon="faSpinner" pulse v-if="type === 'waiting'"/> + </div> + <header v-if="title" v-html="title"></header> + <header v-if="title == null && user">{{ $t('enterUsername') }}</header> + <div class="body" v-if="text" v-html="text"></div> + <mk-input v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></mk-input> + <mk-input v-if="user" v-model="userInputValue" autofocus @keydown="onInputKeydown"><template #prefix>@</template></mk-input> + <mk-select v-if="select" v-model="selectedValue" autofocus> + <template v-if="select.items"> + <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> + </template> + <template v-else> + <optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label"> + <option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option> + </optgroup> + </template> + </mk-select> + <div class="buttons" v-if="!iconOnly && (showOkButton || showCancelButton) && !actions"> + <mk-button inline @click="ok" v-if="showOkButton" primary :autofocus="!input && !select && !user" :disabled="!canOk">{{ (showCancelButton || input || select || user) ? $t('ok') : $t('gotIt') }}</mk-button> + <mk-button inline @click="cancel" v-if="showCancelButton || input || select || user">{{ $t('cancel') }}</mk-button> + </div> + <div class="buttons" v-if="actions"> + <mk-button v-for="action in actions" inline @click="() => { action.callback(); close(); }" :primary="action.primary" :key="action.text">{{ action.text }}</mk-button> + </div> + </template> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faSpinner, faInfoCircle, faExclamationTriangle, faCheck } from '@fortawesome/free-solid-svg-icons'; +import { faTimesCircle, faQuestionCircle } from '@fortawesome/free-regular-svg-icons'; +import MkButton from './ui/button.vue'; +import MkInput from './ui/input.vue'; +import MkSelect from './ui/select.vue'; +import parseAcct from '../../misc/acct/parse'; +import i18n from '../i18n'; + +export default Vue.extend({ + i18n, + + components: { + MkButton, + MkInput, + MkSelect, + }, + + props: { + type: { + type: String, + required: false, + default: 'info' + }, + title: { + type: String, + required: false + }, + text: { + type: String, + required: false + }, + input: { + required: false + }, + select: { + required: false + }, + user: { + required: false + }, + icon: { + required: false + }, + actions: { + required: false + }, + showOkButton: { + type: Boolean, + default: true + }, + showCancelButton: { + type: Boolean, + default: false + }, + cancelableByBgClick: { + type: Boolean, + default: true + }, + iconOnly: { + type: Boolean, + default: false + }, + autoClose: { + type: Boolean, + default: false + } + }, + + data() { + return { + show: true, + inputValue: this.input && this.input.default ? this.input.default : null, + userInputValue: null, + selectedValue: this.select ? this.select.default ? this.select.default : this.select.items ? this.select.items[0].value : this.select.groupedItems[0].items[0].value : null, + canOk: true, + faTimesCircle, faQuestionCircle, faSpinner, faInfoCircle, faExclamationTriangle, faCheck + }; + }, + + watch: { + userInputValue() { + if (this.user) { + this.$root.api('users/show', parseAcct(this.userInputValue)).then(u => { + this.canOk = u != null; + }).catch(() => { + this.canOk = false; + }); + } + } + }, + + mounted() { + if (this.user) this.canOk = false; + + if (this.autoClose) { + setTimeout(() => { + this.close(); + }, 1000); + } + + document.addEventListener('keydown', this.onKeydown); + }, + + beforeDestroy() { + document.removeEventListener('keydown', this.onKeydown); + }, + + methods: { + async ok() { + if (!this.canOk) return; + if (!this.showOkButton) return; + + 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() { + this.$emit('cancel'); + this.close(); + }, + + close() { + if (!this.show) return; + this.show = false; + this.$el.style.pointerEvents = 'none'; + (this.$refs.bg as any).style.pointerEvents = 'none'; + (this.$refs.main as any).style.pointerEvents = 'none'; + }, + + onBgClick() { + if (this.cancelableByBgClick) { + this.cancel(); + } + }, + + onKeydown(e) { + if (e.which === 27) { // ESC + this.cancel(); + } + }, + + onInputKeydown(e) { + if (e.which === 13) { // Enter + e.preventDefault(); + e.stopPropagation(); + this.ok(); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.dialog-enter-active, .dialog-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.dialog-enter, .dialog-leave-to { + opacity: 0; + transform: scale(0.9); +} + +.bg-fade-enter-active, .bg-fade-leave-active { + transition: opacity 0.3s !important; +} +.bg-fade-enter, .bg-fade-leave-to { + opacity: 0; +} + +.mk-dialog { + display: flex; + align-items: center; + justify-content: center; + position: fixed; + z-index: 30000; + top: 0; + left: 0; + width: 100%; + height: 100%; + + &.iconOnly > .main { + min-width: 0; + width: initial; + } + + > .bg { + display: block; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.7); + } + + > .main { + display: block; + position: fixed; + margin: auto; + padding: 32px; + min-width: 320px; + max-width: 480px; + box-sizing: border-box; + width: calc(100% - 32px); + text-align: center; + background: var(--panel); + border-radius: var(--radius); + + > .icon { + font-size: 32px; + + &.success { + color: var(--accent); + } + + &.error { + color: #ec4137; + } + + &.warning { + color: #ecb637; + } + + > * { + display: block; + margin: 0 auto; + } + + & + header { + margin-top: 16px; + } + } + + > header { + margin: 0 0 8px 0; + font-weight: bold; + font-size: 20px; + + & + .body { + margin-top: 8px; + } + } + + > .body { + margin: 16px 0 0 0; + } + + > .buttons { + margin-top: 16px; + + > * { + margin: 0 8px; + } + } + } +} +</style> diff --git a/src/client/app/common/views/components/drive-file-thumbnail.vue b/src/client/components/drive-file-thumbnail.vue index f44223ad6f..37a884dc3d 100644 --- a/src/client/app/common/views/components/drive-file-thumbnail.vue +++ b/src/client/components/drive-file-thumbnail.vue @@ -36,7 +36,6 @@ <script lang="ts"> import Vue from 'vue'; -import anime from 'animejs'; import { faFile, faFileAlt, @@ -120,12 +119,7 @@ export default Vue.extend({ methods: { onThumbnailLoaded() { if (this.file.properties.avgColor) { - anime({ - targets: this.$refs.thumbnail, - backgroundColor: 'transparent', // TODO fade - duration: 100, - easing: 'linear' - }); + this.$refs.thumbnail.style.backgroundColor = 'transparent'; } }, volumechange() { @@ -136,49 +130,59 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.zdjebgpv - display flex +<style lang="scss" scoped> +.zdjebgpv { + display: flex; > img, - > .icon - pointer-events none + > .icon { + pointer-events: none; + } - > .icon-sub - position absolute - width 30% - height auto - margin 0 - right 4% - bottom 4% + > .icon-sub { + position: absolute; + width: 30%; + height: auto; + margin: 0; + right: 4%; + bottom: 4%; + } - > * - margin auto + > * { + margin: auto; + } - &:not(.detail) - > img - height 100% - width 100% - object-fit cover + &:not(.detail) { + > img { + height: 100%; + width: 100%; + object-fit: cover; + } - > .icon - height 65% - width 65% + > .icon { + height: 65%; + width: 65%; + } > video, - > audio - width 100% - - &.detail - > .icon - height 100px - width 100px - margin 16px + > audio { + width: 100%; + } + } - > *:not(.icon) - max-height 300px - max-width 100% - height 100% - object-fit contain + &.detail { + > .icon { + height: 100px; + width: 100px; + margin: 16px; + } + > *:not(.icon) { + max-height: 300px; + max-width: 100%; + height: 100%; + object-fit: contain; + } + } +} </style> diff --git a/src/client/components/drive-window.vue b/src/client/components/drive-window.vue new file mode 100644 index 0000000000..64c4cee0c1 --- /dev/null +++ b/src/client/components/drive-window.vue @@ -0,0 +1,53 @@ +<template> +<x-window ref="window" @closed="() => { $emit('closed'); destroyDom(); }" :with-ok-button="true" :ok-button-disabled="selected.length === 0" @ok="ok()"> + <template #header>{{ multiple ? $t('selectFiles') : $t('selectFile') }}<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length | number }})</span></template> + <div> + <x-drive :multiple="multiple" @change-selection="onChangeSelection" :select-mode="true"/> + </div> +</x-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import XDrive from './drive.vue'; +import XWindow from './window.vue'; + +export default Vue.extend({ + i18n, + + components: { + XDrive, + XWindow, + }, + + props: { + type: { + type: String, + required: false, + default: undefined + }, + multiple: { + type: Boolean, + default: false + } + }, + + data() { + return { + selected: [] + }; + }, + + methods: { + ok() { + this.$emit('selected', this.selected); + this.$refs.window.close(); + }, + + onChangeSelection(files) { + this.selected = files; + } + } +}); +</script> diff --git a/src/client/components/drive.file.vue b/src/client/components/drive.file.vue new file mode 100644 index 0000000000..22fc8c6fb7 --- /dev/null +++ b/src/client/components/drive.file.vue @@ -0,0 +1,368 @@ +<template> +<div class="ncvczrfv" + :data-is-selected="isSelected" + @click="onClick" + draggable="true" + @dragstart="onDragstart" + @dragend="onDragend" + :title="title" +> + <div class="label" v-if="$store.state.i.avatarId == file.id"> + <img src="/assets/label.svg"/> + <p>{{ $t('avatar') }}</p> + </div> + <div class="label" v-if="$store.state.i.bannerId == file.id"> + <img src="/assets/label.svg"/> + <p>{{ $t('banner') }}</p> + </div> + <div class="label red" v-if="file.isSensitive"> + <img src="/assets/label-red.svg"/> + <p>{{ $t('nsfw') }}</p> + </div> + + <x-file-thumbnail class="thumbnail" :file="file" fit="contain"/> + + <p class="name"> + <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> + <span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> + </p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../i18n'; +import copyToClipboard from '../scripts/copy-to-clipboard'; +//import updateAvatar from '../api/update-avatar'; +//import updateBanner from '../api/update-banner'; +import XFileThumbnail from './drive-file-thumbnail.vue'; +import { faDownload, faLink, faICursor, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + i18n, + + props: { + file: { + type: Object, + required: true, + }, + selectMode: { + type: Boolean, + required: false, + default: false, + } + }, + + components: { + XFileThumbnail + }, + + data() { + return { + isDragging: false + }; + }, + + computed: { + browser(): any { + return this.$parent; + }, + isSelected(): boolean { + return this.browser.selectedFiles.some(f => f.id == this.file.id); + }, + title(): string { + return `${this.file.name}\n${this.file.type} ${Vue.filter('bytes')(this.file.size)}`; + } + }, + + methods: { + onClick(ev) { + if (this.selectMode) { + this.browser.chooseFile(this.file); + } else { + this.$root.menu({ + items: [{ + type: 'item', + text: this.$t('rename'), + icon: faICursor, + action: this.rename + }, { + type: 'item', + text: this.file.isSensitive ? this.$t('unmarkAsSensitive') : this.$t('markAsSensitive'), + icon: this.file.isSensitive ? faEye : faEyeSlash, + action: this.toggleSensitive + }, null, { + type: 'item', + text: this.$t('copyUrl'), + icon: faLink, + action: this.copyUrl + }, { + type: 'a', + href: this.file.url, + target: '_blank', + text: this.$t('download'), + icon: faDownload, + download: this.file.name + }, null, { + type: 'item', + text: this.$t('delete'), + icon: faTrashAlt, + action: this.deleteFile + }, null, { + type: 'nest', + text: this.$t('contextmenu.else-files'), + menu: [{ + type: 'item', + text: this.$t('contextmenu.set-as-avatar'), + action: this.setAsAvatar + }, { + type: 'item', + text: this.$t('contextmenu.set-as-banner'), + action: this.setAsBanner + }] + }], + source: ev.currentTarget || ev.target, + }); + } + }, + + onDragstart(e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('mk_drive_file', JSON.stringify(this.file)); + this.isDragging = true; + + // 親ブラウザに対して、ドラッグが開始されたフラグを立てる + // (=あなたの子供が、ドラッグを開始しましたよ) + this.browser.isDragSource = true; + }, + + onDragend(e) { + this.isDragging = false; + this.browser.isDragSource = false; + }, + + onThumbnailLoaded() { + if (this.file.properties.avgColor) { + anime({ + targets: this.$refs.thumbnail, + backgroundColor: 'transparent', // TODO fade + duration: 100, + easing: 'linear' + }); + } + }, + + rename() { + this.$root.dialog({ + title: this.$t('contextmenu.rename-file'), + input: { + placeholder: this.$t('contextmenu.input-new-file-name'), + default: this.file.name, + allowEmpty: false + } + }).then(({ canceled, result: name }) => { + if (canceled) return; + this.$root.api('drive/files/update', { + fileId: this.file.id, + name: name + }); + }); + }, + + toggleSensitive() { + this.$root.api('drive/files/update', { + fileId: this.file.id, + isSensitive: !this.file.isSensitive + }); + }, + + copyUrl() { + copyToClipboard(this.file.url); + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }, + + setAsAvatar() { + updateAvatar(this.$root)(this.file); + }, + + setAsBanner() { + updateBanner(this.$root)(this.file); + }, + + addApp() { + alert('not implemented yet'); + }, + + async deleteFile() { + const { canceled } = await this.$root.dialog({ + type: 'warning', + text: this.$t('driveFileDeleteConfirm', { name: this.file.name }), + showCancelButton: true + }); + if (canceled) return; + + this.$root.api('drive/files/delete', { + fileId: this.file.id + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.ncvczrfv { + position: relative; + padding: 8px 0 0 0; + min-height: 180px; + border-radius: 4px; + + &, * { + cursor: pointer; + } + + &:hover { + background: rgba(#000, 0.05); + + > .label { + &:before, + &:after { + background: #0b65a5; + } + + &.red { + &:before, + &:after { + background: #c12113; + } + } + } + } + + &:active { + background: rgba(#000, 0.1); + + > .label { + &:before, + &:after { + background: #0b588c; + } + + &.red { + &:before, + &:after { + background: #ce2212; + } + } + } + } + + &[data-is-selected] { + background: var(--accent); + + &:hover { + background: var(--accentLighten); + } + + &:active { + background: var(--accentDarken); + } + + > .label { + &:before, + &:after { + display: none; + } + } + + > .name { + color: #fff; + } + + > .thumbnail { + color: #fff; + } + } + + > .label { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + + &:before, + &:after { + content: ""; + display: block; + position: absolute; + z-index: 1; + background: #0c7ac9; + } + + &:before { + top: 0; + left: 57px; + width: 28px; + height: 8px; + } + + &:after { + top: 57px; + left: 0; + width: 8px; + height: 28px; + } + + &.red { + &:before, + &:after { + background: #c12113; + } + } + + > img { + position: absolute; + z-index: 2; + top: 0; + left: 0; + } + + > p { + position: absolute; + z-index: 3; + top: 19px; + left: -28px; + width: 120px; + margin: 0; + text-align: center; + line-height: 28px; + color: #fff; + transform: rotate(-45deg); + } + } + + > .thumbnail { + width: 128px; + height: 128px; + margin: auto; + color: var(--driveFileIcon); + } + + > .name { + display: block; + margin: 4px 0 0 0; + font-size: 0.8em; + text-align: center; + word-break: break-all; + color: var(--fg); + overflow: hidden; + + > .ext { + opacity: 0.5; + } + } +} +</style> diff --git a/src/client/app/desktop/views/components/drive.folder.vue b/src/client/components/drive.folder.vue index cf59d51b01..39a9588772 100644 --- a/src/client/app/desktop/views/components/drive.folder.vue +++ b/src/client/components/drive.folder.vue @@ -1,6 +1,5 @@ <template> -<div class="ynntpczxvnusfwdyxsfuhvcmuypqopdd" - :data-is-contextmenu-showing="isContextmenuShowing" +<div class="rghtznwe" :data-draghover="draghover" @click="onClick" @mouseover="onMouseover" @@ -12,12 +11,11 @@ draggable="true" @dragstart="onDragstart" @dragend="onDragend" - @contextmenu.prevent.stop="onContextmenu" :title="title" > <p class="name"> - <template v-if="hover"><fa :icon="['far', 'folder-open']" fixed-width/></template> - <template v-if="!hover"><fa :icon="['far', 'folder']" fixed-width/></template> + <template v-if="hover"><fa :icon="faFolderOpen" fixed-width/></template> + <template v-if="!hover"><fa :icon="faFolder" fixed-width/></template> {{ folder.name }} </p> <p class="upload" v-if="$store.state.settings.uploadFolder == folder.id"> @@ -28,19 +26,28 @@ <script lang="ts"> import Vue from 'vue'; -import i18n from '../../../i18n'; +import { faFolder, faFolderOpen } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../i18n'; export default Vue.extend({ - i18n: i18n('desktop/views/components/drive.folder.vue'), - props: ['folder'], + i18n, + + props: { + folder: { + type: Object, + required: true, + } + }, + data() { return { hover: false, draghover: false, isDragging: false, - isContextmenuShowing: false + faFolder, faFolderOpen }; }, + computed: { browser(): any { return this.$parent; @@ -54,43 +61,6 @@ export default Vue.extend({ this.browser.move(this.folder); }, - onContextmenu(e) { - this.isContextmenuShowing = true; - this.$contextmenu(e, [{ - type: 'item', - text: this.$t('contextmenu.move-to-this-folder'), - icon: 'arrow-right', - action: this.go - }, { - type: 'item', - text: this.$t('contextmenu.show-in-new-window'), - icon: ['far', 'window-restore'], - action: this.newWindow - }, null, { - type: 'item', - text: this.$t('contextmenu.rename'), - icon: 'i-cursor', - action: this.rename - }, null, { - type: 'item', - text: this.$t('@.delete'), - icon: ['far', 'trash-alt'], - action: this.deleteFolder - }, null, { - type: 'nest', - text: this.$t('contextmenu.else-folders'), - menu: [{ - type: 'item', - text: this.$t('contextmenu.set-as-upload-folder'), - action: this.setAsUploadFolder - }] - }], { - closed: () => { - this.isContextmenuShowing = false; - } - }); - }, - onMouseover() { this.hover = true; }, @@ -259,55 +229,53 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.ynntpczxvnusfwdyxsfuhvcmuypqopdd - padding 8px - height 64px - background var(--desktopDriveFolderBg) - border-radius 4px - - &, * - cursor pointer +<style lang="scss" scoped> +.rghtznwe { + position: relative; + padding: 8px; + height: 64px; + background: var(--driveFolderBg); + border-radius: 4px; - * - pointer-events none - - &:hover - background var(--desktopDriveFolderHoverBg) - - &:active - background var(--desktopDriveFolderActiveBg) - - &[data-is-contextmenu-showing] - &[data-draghover] - &:after - content "" - pointer-events none - position absolute - top -4px - right -4px - bottom -4px - left -4px - border 2px dashed var(--primaryAlpha03) - border-radius 4px + &, * { + cursor: pointer; + } - &[data-draghover] - background var(--desktopDriveFolderActiveBg) + * { + pointer-events: none; + } - > .name - margin 0 - font-size 0.9em - color var(--desktopDriveFolderFg) + &[data-draghover] { + &:after { + content: ""; + pointer-events: none; + position: absolute; + top: -4px; + right: -4px; + bottom: -4px; + left: -4px; + border: 2px dashed var(--focus); + border-radius: 4px; + } + } - > [data-icon] - margin-right 4px - margin-left 2px - text-align left + > .name { + margin: 0; + font-size: 0.9em; + color: var(--desktopDriveFolderFg); - > .upload - margin 4px 4px - font-size 0.8em - text-align right - color var(--desktopDriveFolderFg) + > [data-icon] { + margin-right: 4px; + margin-left: 2px; + text-align: left; + } + } + > .upload { + margin: 4px 4px; + font-size: 0.8em; + text-align: right; + color: var(--desktopDriveFolderFg); + } +} </style> diff --git a/src/client/app/desktop/views/components/drive.nav-folder.vue b/src/client/components/drive.nav-folder.vue index 14ab467642..0689faecd2 100644 --- a/src/client/app/desktop/views/components/drive.nav-folder.vue +++ b/src/client/components/drive.nav-folder.vue @@ -1,5 +1,5 @@ <template> -<div class="root nav-folder" +<div class="drylbebk" :data-draghover="draghover" @click="onClick" @dragover.prevent.stop="onDragover" @@ -7,38 +7,53 @@ @dragleave="onDragleave" @drop.stop="onDrop" > - <i v-if="folder == null" class="cloud"><fa icon="cloud"/></i> - <span>{{ folder == null ? $t('@.drive') : folder.name }}</span> + <i v-if="folder == null"><fa :icon="faCloud"/></i> + <span>{{ folder == null ? $t('drive') : folder.name }}</span> </div> </template> <script lang="ts"> import Vue from 'vue'; -import i18n from '../../../i18n'; +import { faCloud } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; + export default Vue.extend({ - i18n: i18n(), - props: ['folder'], + i18n, + + props: { + folder: { + type: Object, + required: false, + } + }, + data() { return { hover: false, - draghover: false + draghover: false, + faCloud }; }, + computed: { browser(): any { return this.$parent; } }, + methods: { onClick() { this.browser.move(this.folder); }, + onMouseover() { this.hover = true; }, + onMouseout() { this.hover = false; }, + onDragover(e) { // このフォルダがルートかつカレントディレクトリならドロップ禁止 if (this.folder == null && this.browser.folder == null) { @@ -57,12 +72,15 @@ export default Vue.extend({ return false; }, + onDragenter() { if (this.folder || this.browser.folder) this.draghover = true; }, + onDragleave() { if (this.folder || this.browser.folder) this.draghover = false; }, + onDrop(e) { this.draghover = false; @@ -104,15 +122,18 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.root.nav-folder - > * - pointer-events none - - &[data-draghover] - background #eee +<style lang="scss" scoped> +.drylbebk { + > * { + pointer-events: none; + } - i.cloud - margin-right 4px + &[data-draghover] { + background: #eee; + } + > i { + margin-right: 4px; + } +} </style> diff --git a/src/client/app/desktop/views/components/drive.vue b/src/client/components/drive.vue index ff4ff18e6e..2279e2eb6e 100644 --- a/src/client/app/desktop/views/components/drive.vue +++ b/src/client/components/drive.vue @@ -1,76 +1,71 @@ <template> -<div class="mk-drive"> +<div class="yfudmmck"> <nav> <div class="path" @contextmenu.prevent.stop="() => {}"> <x-nav-folder :class="{ current: folder == null }"/> <template v-for="folder in hierarchyFolders"> - <span class="separator"><fa icon="angle-right"/></span> + <span class="separator"><fa :icon="faAngleRight"/></span> <x-nav-folder :folder="folder" :key="folder.id"/> </template> - <span class="separator" v-if="folder != null"><fa icon="angle-right"/></span> + <span class="separator" v-if="folder != null"><fa :icon="faAngleRight"/></span> <span class="folder current" v-if="folder != null">{{ folder.name }}</span> </div> </nav> <div class="main" :class="{ uploading: uploadings.length > 0, fetching }" ref="main" - @mousedown="onMousedown" @dragover.prevent.stop="onDragover" @dragenter="onDragenter" @dragleave="onDragleave" @drop.prevent.stop="onDrop" - @contextmenu.prevent.stop="onContextmenu" > - <div class="selection" ref="selection"></div> <div class="contents" ref="contents"> - <div class="folders" ref="foldersContainer" v-if="folders.length > 0 || moreFolders"> + <div class="folders" ref="foldersContainer" v-if="folders.length > 0"> <x-folder v-for="folder in folders" :key="folder.id" class="folder" :folder="folder"/> <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> <div class="padding" v-for="n in 16"></div> - <ui-button v-if="moreFolders">{{ $t('@.load-more') }}</ui-button> + <mk-button v-if="moreFolders">{{ $t('@.load-more') }}</mk-button> </div> - <div class="files" ref="filesContainer" v-if="files.length > 0 || moreFiles"> - <x-file v-for="file in files" :key="file.id" class="file" :file="file"/> + <div class="files" ref="filesContainer" v-if="files.length > 0"> + <x-file v-for="file in files" :key="file.id" class="file" :file="file" :select-mode="selectMode"/> <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> <div class="padding" v-for="n in 16"></div> - <ui-button v-if="moreFiles" @click="fetchMoreFiles">{{ $t('@.load-more') }}</ui-button> + <mk-button v-if="moreFiles" @click="fetchMoreFiles">{{ $t('@.load-more') }}</mk-button> </div> - <div class="empty" v-if="files.length == 0 && !moreFiles && folders.length == 0 && !moreFolders && !fetching"> + <div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching"> <p v-if="draghover">{{ $t('empty-draghover') }}</p> - <p v-if="!draghover && folder == null"><strong>{{ $t('empty-drive') }}</strong><br/>{{ $t('empty-drive-description') }}</p> - <p v-if="!draghover && folder != null">{{ $t('empty-folder') }}</p> - </div> - </div> - <div class="fetching" v-if="fetching"> - <div class="spinner"> - <div class="dot1"></div> - <div class="dot2"></div> + <p v-if="!draghover && folder == null"><strong>{{ $t('emptyDrive') }}</strong><br/>{{ $t('empty-drive-description') }}</p> + <p v-if="!draghover && folder != null">{{ $t('emptyFolder') }}</p> </div> </div> + <mk-loading v-if="fetching"/> </div> <div class="dropzone" v-if="draghover"></div> - <mk-uploader ref="uploader" @change="onChangeUploaderUploads" @uploaded="onUploaderUploaded"/> + <x-uploader ref="uploader" @change="onChangeUploaderUploads" @uploaded="onUploaderUploaded"/> <input ref="fileInput" type="file" accept="*/*" multiple="multiple" tabindex="-1" @change="onChangeFileInput"/> </div> </template> <script lang="ts"> import Vue from 'vue'; -import i18n from '../../../i18n'; -import MkDriveWindow from './drive-window.vue'; +import { faAngleRight } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; import XNavFolder from './drive.nav-folder.vue'; import XFolder from './drive.folder.vue'; import XFile from './drive.file.vue'; -import contains from '../../../common/scripts/contains'; -import { url } from '../../../config'; -import { faCloudUploadAlt } from '@fortawesome/free-solid-svg-icons'; +import XUploader from './uploader.vue'; +import MkButton from './ui/button.vue'; export default Vue.extend({ - i18n: i18n('desktop/views/components/drive.vue'), + i18n, + components: { XNavFolder, XFolder, - XFile + XFile, + XUploader, + MkButton, }, + props: { initFolder: { type: Object, @@ -79,13 +74,20 @@ export default Vue.extend({ type: { type: String, required: false, - default: undefined + default: undefined }, multiple: { type: Boolean, + required: false, + default: false + }, + selectMode: { + type: Boolean, + required: false, default: false } }, + data() { return { /** @@ -114,9 +116,18 @@ export default Vue.extend({ */ isDragSource: false, - fetching: true + fetching: true, + + faAngleRight }; }, + + watch: { + folder() { + this.$emit('cd', this.folder); + } + }, + mounted() { this.connection = this.$root.stream.useSharedConnection('drive'); @@ -133,29 +144,12 @@ export default Vue.extend({ this.fetch(); } }, + beforeDestroy() { this.connection.dispose(); }, - methods: { - onContextmenu(e) { - this.$contextmenu(e, [{ - type: 'item', - text: this.$t('contextmenu.create-folder'), - icon: ['far', 'folder'], - action: this.createFolder - }, { - type: 'item', - text: this.$t('contextmenu.upload'), - icon: 'upload', - action: this.selectLocalFile - }, { - type: 'item', - text: this.$t('contextmenu.url-upload'), - icon: faCloudUploadAlt, - action: this.urlUpload - }]); - }, + methods: { onStreamDriveFileCreated(file) { this.addFile(file, true); }, @@ -198,53 +192,6 @@ export default Vue.extend({ this.addFile(file, true); }, - onMousedown(e): any { - if (contains(this.$refs.foldersContainer, e.target) || contains(this.$refs.filesContainer, e.target)) return true; - - const main = this.$refs.main as any; - const selection = this.$refs.selection as any; - - const rect = main.getBoundingClientRect(); - - const left = e.pageX + main.scrollLeft - rect.left - window.pageXOffset - const top = e.pageY + main.scrollTop - rect.top - window.pageYOffset - - const move = e => { - selection.style.display = 'block'; - - const cursorX = e.pageX + main.scrollLeft - rect.left - window.pageXOffset; - const cursorY = e.pageY + main.scrollTop - rect.top - window.pageYOffset; - const w = cursorX - left; - const h = cursorY - top; - - if (w > 0) { - selection.style.width = w + 'px'; - selection.style.left = left + 'px'; - } else { - selection.style.width = -w + 'px'; - selection.style.left = cursorX + 'px'; - } - - if (h > 0) { - selection.style.height = h + 'px'; - selection.style.top = top + 'px'; - } else { - selection.style.height = -h + 'px'; - selection.style.top = cursorY + 'px'; - } - }; - - const up = e => { - document.documentElement.removeEventListener('mousemove', move); - document.documentElement.removeEventListener('mouseup', up); - - selection.style.display = 'none'; - }; - - document.documentElement.addEventListener('mousemove', move); - document.documentElement.addEventListener('mouseup', up); - }, - onDragover(e): any { // ドラッグ元が自分自身の所有するアイテムだったら if (this.isDragSource) { @@ -402,18 +349,6 @@ export default Vue.extend({ } }, - newWindow(folder) { - if (document.body.clientWidth > 800) { - this.$root.new(MkDriveWindow, { - folder: folder - }); - } else { - window.open(`${url}/i/drive/folder/${folder.id}`, - 'drive_window', - 'height=500, width=800'); - } - }, - move(target) { if (target == null) { this.goRoot(); @@ -590,171 +525,140 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.mk-drive - > nav - display block - z-index 2 - width 100% - overflow auto - font-size 0.9em - color var(--text) - background var(--face) - box-shadow 0 1px 0 rgba(#000, 0.05) - - &, * - user-select none - - > .path - display inline-block - vertical-align bottom - margin 0 - padding 0 8px - width calc(100% - 200px) - line-height 38px - white-space nowrap - - > * - display inline-block - margin 0 - padding 0 8px - line-height 38px - cursor pointer - - * - pointer-events none - - &:hover - text-decoration underline - - &.current - font-weight bold - cursor default +<style lang="scss" scoped> +.yfudmmck { + > nav { + display: block; + z-index: 2; + width: 100%; + overflow: auto; + font-size: 0.9em; + box-shadow: 0 1px 0 var(--divider); - &:hover - text-decoration none - - &.separator - margin 0 - padding 0 - opacity 0.5 - cursor default - - > [data-icon] - margin 0 - - > .main - padding 8px - height calc(100% - 38px) - overflow auto - background var(--desktopDriveBg) + &, * { + user-select: none; + } - &, * - user-select none + > .path { + display: inline-block; + vertical-align: bottom; + line-height: 38px; + white-space: nowrap; - &.fetching - cursor wait !important + > * { + display: inline-block; + margin: 0; + padding: 0 8px; + line-height: 38px; + cursor: pointer; - * - pointer-events none + * { + pointer-events: none; + } - > .contents - opacity 0.5 + &:hover { + text-decoration: underline; + } - &.uploading - height calc(100% - 38px - 100px) + &.current { + font-weight: bold; + cursor: default; - > .selection - display none - position absolute - z-index 128 - top 0 - left 0 - border solid 1px var(--primary) - background var(--primaryAlpha05) - pointer-events none + &:hover { + text-decoration: none; + } + } - > .contents + &.separator { + margin: 0; + padding: 0; + opacity: 0.5; + cursor: default; - > .folders - > .files - display flex - flex-wrap wrap + > [data-icon] { + margin: 0; + } + } + } + } + } - > .folder - > .file - flex-grow 1 - width 144px - margin 4px + > .main { + padding: 8px 0; + overflow: auto; - > .padding - flex-grow 1 - pointer-events none - width 144px + 8px // 8px is margin + &, * { + user-select: none; + } - > .empty - padding 16px - text-align center - color #999 - pointer-events none + &.fetching { + cursor: wait !important; - > p - margin 0 + * { + pointer-events: none; + } - > .fetching - .spinner - margin 100px auto - width 40px - height 40px - text-align center + > .contents { + opacity: 0.5; + } + } - animation sk-rotate 2.0s infinite linear + &.uploading { + height: calc(100% - 38px - 100px); + } - .dot1, .dot2 - width 60% - height 60% - display inline-block - position absolute - top 0 - background-color rgba(#000, 0.3) - border-radius 100% + > .contents { - animation sk-bounce 2.0s infinite ease-in-out + > .folders, + > .files { + display: flex; + flex-wrap: wrap; - .dot2 - top auto - bottom 0 - animation-delay -1.0s + > .folder, + > .file { + flex-grow: 1; + width: 144px; + margin: 4px; + box-sizing: border-box; + } - @keyframes sk-rotate { - 100% { - transform: rotate(360deg); + > .padding { + flex-grow: 1; + pointer-events: none; + width: 144px + 8px; } } - @keyframes sk-bounce { - 0%, 100% { - transform: scale(0.0); - } - 50% { - transform: scale(1.0); + > .empty { + padding: 16px; + text-align: center; + pointer-events: none; + opacity: 0.5; + + > p { + margin: 0; } } + } + } - > .dropzone - position absolute - left 0 - top 38px - width 100% - height calc(100% - 38px) - border dashed 2px var(--primaryAlpha05) - pointer-events none - - > .mk-uploader - height 100px - padding 16px + > .dropzone { + position: absolute; + left: 0; + top: 38px; + width: 100%; + height: calc(100% - 38px); + border: dashed 2px var(--focus); + pointer-events: none; + } - > input - display none + > .mk-uploader { + height: 100px; + padding: 16px; + } + > input { + display: none; + } +} </style> diff --git a/src/client/components/ellipsis.vue b/src/client/components/ellipsis.vue new file mode 100644 index 0000000000..0a46f486d6 --- /dev/null +++ b/src/client/components/ellipsis.vue @@ -0,0 +1,34 @@ +<template> + <span class="mk-ellipsis"> + <span>.</span><span>.</span><span>.</span> + </span> +</template> + +<style lang="scss" scoped> +.mk-ellipsis { + > span { + animation: ellipsis 1.4s infinite ease-in-out both; + + &:nth-child(1) { + animation-delay: 0s; + } + + &:nth-child(2) { + animation-delay: 0.16s; + } + + &:nth-child(3) { + animation-delay: 0.32s; + } + } +} + +@keyframes ellipsis { + 0%, 80%, 100% { + opacity: 1; + } + 40% { + opacity: 0; + } +} +</style> diff --git a/src/client/components/emoji-picker.vue b/src/client/components/emoji-picker.vue new file mode 100644 index 0000000000..61d641a023 --- /dev/null +++ b/src/client/components/emoji-picker.vue @@ -0,0 +1,268 @@ +<template> +<x-popup :source="source" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }"> + <div class="omfetrab"> + <header> + <button v-for="category in categories" + class="_button" + :title="category.text" + @click="go(category)" + :class="{ active: category.isActive }" + :key="category.text" + > + <fa :icon="category.icon" fixed-width/> + </button> + </header> + + <div class="emojis"> + <template v-if="categories[0].isActive"> + <header class="category"><fa :icon="faHistory" fixed-width/> {{ $t('recentUsedEmojis') }}</header> + <div class="list"> + <button v-for="(emoji, i) in ($store.state.device.recentEmojis || [])" + class="_button" + :title="emoji.name" + @click="chosen(emoji)" + :key="i" + > + <mk-emoji v-if="emoji.char != null" :emoji="emoji.char"/> + <img v-else :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> + </button> + </div> + </template> + + <header class="category"><fa :icon="categories.find(x => x.isActive).icon" fixed-width/> {{ categories.find(x => x.isActive).text }}</header> + <template v-if="categories.find(x => x.isActive).name"> + <div class="list"> + <button v-for="emoji in emojilist.filter(e => e.category === categories.find(x => x.isActive).name)" + class="_button" + :title="emoji.name" + @click="chosen(emoji)" + :key="emoji.name" + > + <mk-emoji :emoji="emoji.char"/> + </button> + </div> + </template> + <template v-else> + <div v-for="(key, i) in Object.keys(customEmojis)" :key="i"> + <header class="sub" v-if="key">{{ key }}</header> + <div class="list"> + <button v-for="emoji in customEmojis[key]" + class="_button" + :title="emoji.name" + @click="chosen(emoji)" + :key="emoji.name" + > + <img :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> + </button> + </div> + </div> + </template> + </div> + </div> +</x-popup> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import { emojilist } from '../../misc/emojilist'; +import { getStaticImageUrl } from '../scripts/get-static-image-url'; +import { faAsterisk, faLeaf, faUtensils, faFutbol, faCity, faDice, faGlobe, faHistory } from '@fortawesome/free-solid-svg-icons'; +import { faHeart, faFlag, faLaugh } from '@fortawesome/free-regular-svg-icons'; +import { groupByX } from '../../prelude/array'; +import XPopup from './popup.vue'; + +export default Vue.extend({ + i18n, + + components: { + XPopup, + }, + + props: { + source: { + required: true + }, + }, + + data() { + return { + emojilist, + getStaticImageUrl, + customEmojis: {}, + faGlobe, faHistory, + categories: [{ + text: this.$t('customEmoji'), + icon: faAsterisk, + isActive: true + }, { + name: 'people', + text: this.$t('people'), + icon: faLaugh, + isActive: false + }, { + name: 'animals_and_nature', + text: this.$t('animals-and-nature'), + icon: faLeaf, + isActive: false + }, { + name: 'food_and_drink', + text: this.$t('food-and-drink'), + icon: faUtensils, + isActive: false + }, { + name: 'activity', + text: this.$t('activity'), + icon: faFutbol, + isActive: false + }, { + name: 'travel_and_places', + text: this.$t('travel-and-places'), + icon: faCity, + isActive: false + }, { + name: 'objects', + text: this.$t('objects'), + icon: faDice, + isActive: false + }, { + name: 'symbols', + text: this.$t('symbols'), + icon: faHeart, + isActive: false + }, { + name: 'flags', + text: this.$t('flags'), + icon: faFlag, + isActive: false + }] + }; + }, + + created() { + let local = (this.$root.getMetaSync() || { emojis: [] }).emojis || []; + local = groupByX(local, (x: any) => x.category || ''); + this.customEmojis = local; + }, + + methods: { + go(category: any) { + this.goCategory(category.name); + }, + + goCategory(name: string) { + let matched = false; + for (const c of this.categories) { + c.isActive = c.name === name; + if (c.isActive) { + matched = true; + } + } + if (!matched) { + this.categories[0].isActive = true; + } + }, + + chosen(emoji: any) { + const getKey = (emoji: any) => emoji.char || `:${emoji.name}:`; + let recents = this.$store.state.device.recentEmojis || []; + recents = recents.filter((e: any) => getKey(e) !== getKey(emoji)); + recents.unshift(emoji) + this.$store.commit('device/set', { key: 'recentEmojis', value: recents.splice(0, 16) }); + this.$emit('chosen', getKey(emoji)); + }, + + close() { + this.$refs.popup.close(); + } + } +}); +</script> + +<style lang="scss" scoped> +.omfetrab { + width: 350px; + + > header { + display: flex; + + > button { + flex: 1; + padding: 10px 0; + font-size: 16px; + transition: color 0.2s ease; + + &:hover { + color: var(--textHighlighted); + transition: color 0s; + } + + &.active { + color: var(--accent); + transition: color 0s; + } + } + } + + > .emojis { + height: 300px; + overflow-y: auto; + overflow-x: hidden; + + > header.category { + position: sticky; + top: 0; + left: 0; + z-index: 1; + padding: 8px; + background: var(--panel); + font-size: 12px; + } + + header.sub { + padding: 4px 8px; + font-size: 12px; + } + + div.list { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; + gap: 4px; + padding: 8px; + + > button { + position: relative; + padding: 0; + width: 100%; + + &:before { + content: ''; + display: block; + width: 1px; + height: 0; + padding-bottom: 100%; + } + + &:hover { + > * { + transform: scale(1.2); + transition: transform 0s; + } + } + + > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: contain; + font-size: 28px; + transition: transform 0.2s ease; + pointer-events: none; + } + } + } + } +} +</style> diff --git a/src/client/app/common/views/components/emoji.vue b/src/client/components/emoji.vue index 26992c5f7e..2e8bddb803 100644 --- a/src/client/app/common/views/components/emoji.vue +++ b/src/client/components/emoji.vue @@ -1,14 +1,14 @@ <template> -<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"/> +<img v-if="customEmoji" class="mk-emoji custom" :class="{ normal, noStyle }" :src="url" :alt="alt" :title="alt"/> +<img v-else-if="char && !useOsDefaultEmojis" class="mk-emoji" :src="url" :alt="alt" :title="alt"/> <span v-else-if="char && useOsDefaultEmojis">{{ char }}</span> <span v-else>:{{ name }}:</span> </template> <script lang="ts"> import Vue from 'vue'; -import { getStaticImageUrl } from '../../../common/scripts/get-static-image-url'; -import { twemojiSvgBase } from '../../../../../misc/twemoji-base'; +import { getStaticImageUrl } from '../scripts/get-static-image-url'; +import { twemojiSvgBase } from '../../misc/twemoji-base'; export default Vue.extend({ props: { @@ -25,6 +25,11 @@ export default Vue.extend({ required: false, default: false }, + noStyle: { + type: Boolean, + required: false, + default: false + }, customEmojis: { required: false, default: () => [] @@ -96,24 +101,32 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.fvgwvorwhxigeolkkrcderjzcawqrscl - height 1.25em - vertical-align -0.25em +<style lang="scss" scoped> +.mk-emoji { + height: 1.25em; + vertical-align: -0.25em; - &.custom - height 2.5em - vertical-align middle - transition transform 0.2s ease + &.custom { + height: 2.5em; + vertical-align: middle; + transition: transform 0.2s ease; - &:hover - transform scale(1.2) + &:hover { + transform: scale(1.2); + } - &.normal - height 1.25em - vertical-align -0.25em + &.normal { + height: 1.25em; + vertical-align: -0.25em; - &:hover - transform none + &:hover { + transform: none; + } + } + } + &.noStyle { + height: auto !important; + } +} </style> diff --git a/src/client/components/error.vue b/src/client/components/error.vue new file mode 100644 index 0000000000..1dc21dbb19 --- /dev/null +++ b/src/client/components/error.vue @@ -0,0 +1,42 @@ +<template> +<div class="wjqjnyhzogztorhrdgcpqlkxhkmuetgj _panel"> + <p><fa :icon="faExclamationTriangle"/> {{ $t('error') }}</p> + <mk-button @click="() => $emit('retry')" class="button">{{ $t('retry') }}</mk-button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import MkButton from './ui/button.vue'; + +export default Vue.extend({ + i18n, + components: { + MkButton, + }, + data() { + return { + faExclamationTriangle + }; + }, +}); +</script> + +<style lang="scss" scoped> +.wjqjnyhzogztorhrdgcpqlkxhkmuetgj { + max-width: 350px; + margin: 0 auto; + padding: 32px; + text-align: center; + + > p { + margin: 0 0 8px 0; + } + + > .button { + margin: 0 auto; + } +} +</style> diff --git a/src/client/components/file-type-icon.vue b/src/client/components/file-type-icon.vue new file mode 100644 index 0000000000..8492567ad7 --- /dev/null +++ b/src/client/components/file-type-icon.vue @@ -0,0 +1,29 @@ +<template> +<span class="mk-file-type-icon"> + <template v-if="kind == 'image'"><fa :icon="faFileImage"/></template> +</span> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faFileImage } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + props: { + type: { + type: String, + required: true, + } + }, + data() { + return { + faFileImage + }; + }, + computed: { + kind(): string { + return this.type.split('/')[0]; + } + } +}); +</script> diff --git a/src/client/components/follow-button.vue b/src/client/components/follow-button.vue new file mode 100644 index 0000000000..4b57a2bd88 --- /dev/null +++ b/src/client/components/follow-button.vue @@ -0,0 +1,162 @@ +<template> +<button class="wfliddvnhxvyusikowhxozkyxyenqxqr _button" + :class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou }" + @click="onClick" + :disabled="wait" +> + <template v-if="!wait"> + <fa v-if="hasPendingFollowRequestFromYou && user.isLocked" :icon="faHourglassHalf"/> + <fa v-else-if="hasPendingFollowRequestFromYou && !user.isLocked" :icon="faSpinner" pulse/> + <fa v-else-if="isFollowing" :icon="faMinus"/> + <fa v-else-if="!isFollowing && user.isLocked" :icon="faPlus"/> + <fa v-else-if="!isFollowing && !user.isLocked" :icon="faPlus"/> + </template> + <template v-else><fa :icon="faSpinner" pulse fixed-width/></template> +</button> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import { faSpinner, faPlus, faMinus, faHourglassHalf } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + i18n, + + props: { + user: { + type: Object, + required: true + }, + }, + + data() { + return { + isFollowing: this.user.isFollowing, + hasPendingFollowRequestFromYou: this.user.hasPendingFollowRequestFromYou, + wait: false, + connection: null, + faSpinner, faPlus, faMinus, faHourglassHalf + }; + }, + + mounted() { + this.connection = this.$root.stream.useSharedConnection('main'); + + this.connection.on('follow', this.onFollowChange); + this.connection.on('unfollow', this.onFollowChange); + }, + + beforeDestroy() { + this.connection.dispose(); + }, + + methods: { + onFollowChange(user) { + if (user.id == this.user.id) { + this.isFollowing = user.isFollowing; + this.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; + } + }, + + async onClick() { + this.wait = true; + + try { + if (this.isFollowing) { + const { canceled } = await this.$root.dialog({ + type: 'warning', + text: this.$t('unfollowConfirm', { name: this.user.name || this.user.username }), + showCancelButton: true + }); + + if (canceled) return; + + await this.$root.api('following/delete', { + userId: this.user.id + }); + } else { + if (this.hasPendingFollowRequestFromYou) { + await this.$root.api('following/requests/cancel', { + userId: this.user.id + }); + } else if (this.user.isLocked) { + await this.$root.api('following/create', { + userId: this.user.id + }); + this.hasPendingFollowRequestFromYou = true; + } else { + await this.$root.api('following/create', { + userId: this.user.id + }); + this.hasPendingFollowRequestFromYou = true; + } + } + } catch (e) { + console.error(e); + } finally { + this.wait = false; + } + } + } +}); +</script> + +<style lang="scss" scoped> +.wfliddvnhxvyusikowhxozkyxyenqxqr { + position: relative; + display: inline-block; + font-weight: bold; + color: var(--accent); + background: transparent; + border: solid 1px var(--accent); + padding: 0; + width: 31px; + height: 31px; + font-size: 16px; + border-radius: 100%; + background: #fff; + + &:focus { + &:after { + content: ""; + pointer-events: none; + position: absolute; + top: -5px; + right: -5px; + bottom: -5px; + left: -5px; + border: 2px solid var(--focus); + border-radius: 100%; + } + } + + &:hover { + //background: mix($primary, #fff, 20); + } + + &:active { + //background: mix($primary, #fff, 40); + } + + &.active { + color: #fff; + background: var(--accent); + + &:hover { + background: var(--accentLighten); + border-color: var(--accentLighten); + } + + &:active { + background: var(--accentDarken); + border-color: var(--accentDarken); + } + } + + &.wait { + cursor: wait !important; + opacity: 0.7; + } +} +</style> diff --git a/src/client/app/common/views/components/formula-core.vue b/src/client/components/formula-core.vue index 69697d6df0..45b27f9026 100644 --- a/src/client/app/common/views/components/formula-core.vue +++ b/src/client/components/formula-core.vue @@ -1,3 +1,4 @@ + <template> <div v-if="block" v-html="compiledFormula"></div> <span v-else v-html="compiledFormula"></span> @@ -6,7 +7,6 @@ <script lang="ts"> import Vue from 'vue'; import * as katex from 'katex'; - export default Vue.extend({ props: { formula: { @@ -29,5 +29,5 @@ export default Vue.extend({ </script> <style> -@import "../../../../../../node_modules/katex/dist/katex.min.css"; +@import "../../../node_modules/katex/dist/katex.min.css"; </style> diff --git a/src/client/app/common/views/components/formula.vue b/src/client/components/formula.vue index 73572b72c6..4aaad1bf3e 100644 --- a/src/client/app/common/views/components/formula.vue +++ b/src/client/components/formula.vue @@ -4,12 +4,10 @@ <script lang="ts"> import Vue from 'vue'; - export default Vue.extend({ components: { XFormula: () => import('./formula-core.vue').then(m => m.default) }, - props: { formula: { type: String, diff --git a/src/client/components/google.vue b/src/client/components/google.vue new file mode 100644 index 0000000000..e6ef7f7d90 --- /dev/null +++ b/src/client/components/google.vue @@ -0,0 +1,71 @@ +<template> +<div class="mk-google"> + <input type="search" v-model="query" :placeholder="q"> + <button @click="search"><fa icon="search"/> {{ $t('@.search') }}</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; + +export default Vue.extend({ + i18n, + props: ['q'], + data() { + return { + query: null + }; + }, + mounted() { + this.query = this.q; + }, + methods: { + search() { + const engine = this.$store.state.settings.webSearchEngine || + 'https://www.google.com/?#q={{query}}'; + const url = engine.replace('{{query}}', this.query) + window.open(url, '_blank'); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-google { + display: flex; + margin: 8px 0; + + > input { + flex-shrink: 1; + padding: 10px; + width: 100%; + height: 40px; + font-size: 16px; + color: var(--googleSearchFg); + background: var(--googleSearchBg); + border: solid 1px var(--googleSearchBorder); + border-radius: 4px 0 0 4px; + + &:hover { + border-color: var(--googleSearchHoverBorder); + } + } + + > button { + flex-shrink: 0; + padding: 0 16px; + border: solid 1px var(--googleSearchBorder); + border-left: none; + border-radius: 0 4px 4px 0; + + &:hover { + background-color: var(--googleSearchHoverButton); + } + + &:active { + box-shadow: 0 2px 4px rgba(#000, 0.15) inset; + } + } +} +</style> diff --git a/src/client/components/index.ts b/src/client/components/index.ts new file mode 100644 index 0000000000..9385c2af73 --- /dev/null +++ b/src/client/components/index.ts @@ -0,0 +1,25 @@ +import Vue from 'vue'; + +import mfm from './misskey-flavored-markdown.vue'; +import acct from './acct.vue'; +import avatar from './avatar.vue'; +import emoji from './emoji.vue'; +import userName from './user-name.vue'; +import ellipsis from './ellipsis.vue'; +import time from './time.vue'; +import url from './url.vue'; +import loading from './loading.vue'; +import SequentialEntrance from './sequential-entrance.vue'; +import error from './error.vue'; + +Vue.component('mfm', mfm); +Vue.component('mk-acct', acct); +Vue.component('mk-avatar', avatar); +Vue.component('mk-emoji', emoji); +Vue.component('mk-user-name', userName); +Vue.component('mk-ellipsis', ellipsis); +Vue.component('mk-time', time); +Vue.component('mk-url', url); +Vue.component('mk-loading', loading); +Vue.component('mk-error', error); +Vue.component('sequential-entrance', SequentialEntrance); diff --git a/src/client/components/loading.vue b/src/client/components/loading.vue new file mode 100644 index 0000000000..88d1ed77fa --- /dev/null +++ b/src/client/components/loading.vue @@ -0,0 +1,30 @@ +<template> +<div class="yxspomdl"> + <fa :icon="faSpinner" pulse fixed-width class="icon"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + data() { + return { + faSpinner + }; + }, +}); +</script> + +<style lang="scss" scoped> +.yxspomdl { + padding: 32px; + text-align: center; + + > .icon { + font-size: 32px; + opacity: 0.5; + } +} +</style> diff --git a/src/client/app/common/views/components/media-banner.vue b/src/client/components/media-banner.vue index 4e459ad666..088c11fab7 100644 --- a/src/client/app/common/views/components/media-banner.vue +++ b/src/client/components/media-banner.vue @@ -1,9 +1,9 @@ <template> <div class="mk-media-banner"> <div class="sensitive" v-if="media.isSensitive && hide" @click="hide = false"> - <span class="icon"><fa icon="exclamation-triangle"/></span> + <span class="icon"><fa :icon="faExclamationTriangle"/></span> <b>{{ $t('sensitive') }}</b> - <span>{{ $t('click-to-show') }}</span> + <span>{{ $t('clickToShow') }}</span> </div> <div class="audio" v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'"> <audio class="audio" @@ -27,10 +27,11 @@ <script lang="ts"> import Vue from 'vue'; -import i18n from '../../../i18n'; +import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; export default Vue.extend({ - i18n: i18n('common/views/components/media-banner.vue'), + i18n, props: { media: { type: Object, @@ -39,7 +40,8 @@ export default Vue.extend({ }, data() { return { - hide: true + hide: true, + faExclamationTriangle }; }, mounted() { @@ -55,44 +57,53 @@ export default Vue.extend({ }) </script> -<style lang="stylus" scoped> -.mk-media-banner - width 100% - border-radius 4px - margin-top 4px - overflow hidden +<style lang="scss" scoped> +.mk-media-banner { + width: 100%; + border-radius: 4px; + margin-top: 4px; + overflow: hidden; > .download, - > .sensitive - display flex - align-items center - font-size 12px - padding 8px 12px - white-space nowrap + > .sensitive { + display: flex; + align-items: center; + font-size: 12px; + padding: 8px 12px; + white-space: nowrap; - > * - display block - - > b - overflow hidden - text-overflow ellipsis + > * { + display: block; + } - > *:not(:last-child) - margin-right .2em + > b { + overflow: hidden; + text-overflow: ellipsis; + } - > .icon - font-size 1.6em + > *:not(:last-child) { + margin-right: .2em; + } - > .download - background var(--noteAttachedFile) + > .icon { + font-size: 1.6em; + } + } - > .sensitive - background #111 - color #fff + > .download { + background: var(--noteAttachedFile); + } - > .audio - .audio - display block - width 100% + > .sensitive { + background: #111; + color: #fff; + } + > .audio { + .audio { + display: block; + width: 100%; + } + } +} </style> diff --git a/src/client/components/media-image.vue b/src/client/components/media-image.vue new file mode 100644 index 0000000000..5ae167d490 --- /dev/null +++ b/src/client/components/media-image.vue @@ -0,0 +1,113 @@ +<template> +<div class="qjewsnkgzzxlxtzncydssfbgjibiehcy" v-if="image.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false"> + <div> + <b><fa :icon="faExclamationTriangle"/> {{ $t('sensitive') }}</b> + <span>{{ $t('clickToShow') }}</span> + </div> +</div> +<a class="gqnyydlzavusgskkfvwvjiattxdzsqlf" v-else + :href="image.url" + :style="style" + :title="image.name" + @click.prevent="onClick" +> + <div v-if="image.type === 'image/gif'">GIF</div> +</a> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import { getStaticImageUrl } from '../scripts/get-static-image-url'; + +export default Vue.extend({ + i18n, + props: { + image: { + type: Object, + required: true + }, + raw: { + default: false + } + }, + data() { + return { + hide: true, + faExclamationTriangle + }; + }, + computed: { + style(): any { + let url = `url(${ + this.$store.state.device.disableShowingAnimatedImages + ? getStaticImageUrl(this.image.thumbnailUrl) + : this.image.thumbnailUrl + })`; + + if (this.$store.state.device.loadRemoteMedia) { + url = null; + } else if (this.raw || this.$store.state.device.loadRawImages) { + url = `url(${this.image.url})`; + } + + return { + 'background-color': this.image.properties.avgColor || 'transparent', + 'background-image': url + }; + } + }, + methods: { + onClick() { + window.open(this.image.url, '_blank'); + } + } +}); +</script> + +<style lang="scss" scoped> +.gqnyydlzavusgskkfvwvjiattxdzsqlf { + display: block; + cursor: zoom-in; + overflow: hidden; + width: 100%; + height: 100%; + background-position: center; + background-size: contain; + background-repeat: no-repeat; + + > div { + background-color: var(--fg); + border-radius: 6px; + color: var(--secondary); + display: inline-block; + font-size: 14px; + font-weight: bold; + left: 12px; + opacity: .5; + padding: 0 6px; + text-align: center; + top: 12px; + pointer-events: none; + } +} + +.qjewsnkgzzxlxtzncydssfbgjibiehcy { + display: flex; + justify-content: center; + align-items: center; + background: #111; + color: #fff; + + > div { + display: table-cell; + text-align: center; + font-size: 12px; + + > * { + display: block; + } + } +} +</style> diff --git a/src/client/components/media-list.vue b/src/client/components/media-list.vue new file mode 100644 index 0000000000..08722ff91a --- /dev/null +++ b/src/client/components/media-list.vue @@ -0,0 +1,130 @@ +<template> +<div class="mk-media-list"> + <template v-for="media in mediaList.filter(media => !previewable(media))"> + <x-banner :media="media" :key="media.id"/> + </template> + <div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container"> + <div :data-count="mediaList.filter(media => previewable(media)).length" ref="grid"> + <template v-for="media in mediaList"> + <x-video :video="media" :key="media.id" v-if="media.type.startsWith('video')"/> + <x-image :image="media" :key="media.id" v-else-if="media.type.startsWith('image')" :raw="raw"/> + </template> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XBanner from './media-banner.vue'; +import XImage from './media-image.vue'; +import XVideo from './media-video.vue'; + +export default Vue.extend({ + components: { + XBanner, + XImage, + XVideo, + }, + props: { + mediaList: { + required: true + }, + raw: { + default: false + } + }, + mounted() { + //#region for Safari bug + if (this.$refs.grid) { + this.$refs.grid.style.height = this.$refs.grid.clientHeight ? `${this.$refs.grid.clientHeight}px` + : '287px'; + } + //#endregion + }, + methods: { + previewable(file) { + return file.type.startsWith('video') || file.type.startsWith('image'); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-media-list { + > .gird-container { + position: relative; + width: 100%; + margin-top: 4px; + + &:before { + content: ''; + display: block; + padding-top: 56.25% // 16:9; + } + + > div { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: grid; + grid-gap: 4px; + + > * { + overflow: hidden; + border-radius: 4px; + } + + &[data-count="1"] { + grid-template-rows: 1fr; + } + + &[data-count="2"] { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr; + } + + &[data-count="3"] { + grid-template-columns: 1fr 0.5fr; + grid-template-rows: 1fr 1fr; + + > *:nth-child(1) { + grid-row: 1 / 3; + } + + > *:nth-child(3) { + grid-column: 2 / 3; + grid-row: 2 / 3; + } + } + + &[data-count="4"] { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + } + + > *:nth-child(1) { + grid-column: 1 / 2; + grid-row: 1 / 2; + } + + > *:nth-child(2) { + grid-column: 2 / 3; + grid-row: 1 / 2; + } + + > *:nth-child(3) { + grid-column: 1 / 2; + grid-row: 2 / 3; + } + + > *:nth-child(4) { + grid-column: 2 / 3; + grid-row: 2 / 3; + } + } + } +} +</style> diff --git a/src/client/app/mobile/views/components/media-video.vue b/src/client/components/media-video.vue index 044bb4c106..f96e902976 100644 --- a/src/client/app/mobile/views/components/media-video.vue +++ b/src/client/components/media-video.vue @@ -2,7 +2,7 @@ <div class="icozogqfvdetwohsdglrbswgrejoxbdj" v-if="video.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false"> <div> <b><fa icon="exclamation-triangle"/> {{ $t('sensitive') }}</b> - <span>{{ $t('click-to-show') }}</span> + <span>{{ $t('clickToShow') }}</span> </div> </div> <a class="kkjnbbplepmiyuadieoenjgutgcmtsvu" v-else @@ -12,16 +12,17 @@ :style="imageStyle" :title="video.name" > - <fa :icon="['far', 'play-circle']"/> + <fa :icon="faPlayCircle"/> </a> </template> <script lang="ts"> import Vue from 'vue'; -import i18n from '../../../i18n'; +import { faPlayCircle } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../i18n'; export default Vue.extend({ - i18n: i18n('mobile/views/components/media-video.vue'), + i18n, props: { video: { type: Object, @@ -30,7 +31,8 @@ export default Vue.extend({ }, data() { return { - hide: true + hide: true, + faPlayCircle }; }, computed: { @@ -43,32 +45,35 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.kkjnbbplepmiyuadieoenjgutgcmtsvu - display flex - justify-content center - align-items center +<style lang="scss" scoped> +.kkjnbbplepmiyuadieoenjgutgcmtsvu { + display: flex; + justify-content: center; + align-items: center; - font-size 3.5em - overflow hidden - background-position center - background-size cover - width 100% - height 100% + font-size: 3.5em; + overflow: hidden; + background-position: center; + background-size: cover; + width: 100%; + height: 100%; +} -.icozogqfvdetwohsdglrbswgrejoxbdj - display flex - justify-content center - align-items center - background #111 - color #fff +.icozogqfvdetwohsdglrbswgrejoxbdj { + display: flex; + justify-content: center; + align-items: center; + background: #111; + color: #fff; - > div - display table-cell - text-align center - font-size 12px - - > b - display block + > div { + display: table-cell; + text-align: center; + font-size: 12px; + > b { + display: block; + } + } +} </style> diff --git a/src/client/app/common/views/components/mention.vue b/src/client/components/mention.vue index 4e9f9e90d6..06dcf12887 100644 --- a/src/client/app/common/views/components/mention.vue +++ b/src/client/components/mention.vue @@ -1,27 +1,27 @@ <template> <router-link class="ldlomzub" :to="url" v-user-preview="canonical" v-if="url.startsWith('/')"> - <span class="me" v-if="isMe">{{ $t('@.you') }}</span> + <span class="me" v-if="isMe">{{ $t('you') }}</span> <span class="main"> <span class="username">@{{ username }}</span> - <span class="host" :class="{ fade: $store.state.settings.contrastedAcct }" v-if="(host != localHost) || $store.state.settings.showFullAcct">@{{ toUnicode(host) }}</span> + <span class="host" v-if="(host != localHost) || $store.state.settings.showFullAcct">@{{ toUnicode(host) }}</span> </span> </router-link> <a class="ldlomzub" :href="url" target="_blank" rel="noopener" v-else> <span class="main"> <span class="username">@{{ username }}</span> - <span class="host" :class="{ fade: $store.state.settings.contrastedAcct }">@{{ toUnicode(host) }}</span> + <span class="host">@{{ toUnicode(host) }}</span> </span> </a> </template> <script lang="ts"> import Vue from 'vue'; -import i18n from '../../../i18n'; +import i18n from '../i18n'; import { toUnicode } from 'punycode'; -import { host as localHost } from '../../../config'; +import { host as localHost } from '../config'; export default Vue.extend({ - i18n: i18n(), + i18n, props: { username: { type: String, @@ -62,26 +62,21 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.ldlomzub - color var(--mfmMention) - - > .me - pointer-events none - user-select none - padding 0 4px - background var(--mfmMention) - border solid var(--lineWidth) var(--mfmMention) - border-radius 4px 0 0 4px - color var(--mfmMentionForeground) - - & + .main - padding 0 4px - border solid var(--lineWidth) var(--mfmMention) - border-radius 0 4px 4px 0 - - > .main - > .host.fade - opacity 0.5 +<style lang="scss" scoped> +.ldlomzub { + color: var(--mention); + + > .me { + pointer-events: none; + user-select: none; + font-size: 70%; + vertical-align: top; + } + > .main { + > .host { + opacity: 0.5; + } + } +} </style> diff --git a/src/client/components/menu.vue b/src/client/components/menu.vue new file mode 100644 index 0000000000..c1c5ceaee7 --- /dev/null +++ b/src/client/components/menu.vue @@ -0,0 +1,165 @@ +<template> +<x-popup :source="source" :no-center="noCenter" :fixed="fixed" :width="width" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }"> + <sequential-entrance class="rrevdjwt" :class="{ left: align === 'left' }" :delay="15" :direction="direction"> + <template v-for="(item, i) in items.filter(item => item !== undefined)"> + <div v-if="item === null" class="divider" :key="i" :data-index="i"></div> + <span v-else-if="item.type === 'label'" class="label item" :key="i" :data-index="i"> + <span>{{ item.text }}</span> + </span> + <router-link v-else-if="item.type === 'link'" :to="item.to" @click.native="close()" :tabindex="i" class="_button item" :key="i" :data-index="i"> + <fa v-if="item.icon" :icon="item.icon" fixed-width/> + <mk-avatar v-if="item.avatar" :user="item.avatar" class="avatar"/> + <span>{{ item.text }}</span> + <i v-if="item.indicate"><fa :icon="faCircle"/></i> + </router-link> + <a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" @click="close()" :tabindex="i" class="_button item" :key="i" :data-index="i"> + <fa v-if="item.icon" :icon="item.icon" fixed-width/> + <span>{{ item.text }}</span> + <i v-if="item.indicate"><fa :icon="faCircle"/></i> + </a> + <button v-else-if="item.type === 'user'" @click="clicked(item.action)" :tabindex="i" class="_button item" :key="i" :data-index="i"> + <mk-avatar :user="item.user" class="avatar"/><mk-user-name :user="item.user"/> + <i v-if="item.indicate"><fa :icon="faCircle"/></i> + </button> + <button v-else @click="clicked(item.action)" :tabindex="i" class="_button item" :key="i" :data-index="i"> + <fa v-if="item.icon" :icon="item.icon" fixed-width/> + <mk-avatar v-if="item.avatar" :user="item.avatar" class="avatar"/> + <span>{{ item.text }}</span> + <i v-if="item.indicate"><fa :icon="faCircle"/></i> + </button> + </template> + </sequential-entrance> +</x-popup> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faCircle } from '@fortawesome/free-solid-svg-icons'; +import XPopup from './popup.vue'; + +export default Vue.extend({ + components: { + XPopup + }, + props: { + source: { + required: true + }, + items: { + type: Array, + required: true + }, + align: { + type: String, + required: false + }, + noCenter: { + type: Boolean, + required: false + }, + fixed: { + type: Boolean, + required: false + }, + width: { + type: Number, + required: false + }, + direction: { + type: String, + required: false + }, + }, + data() { + return { + faCircle + }; + }, + methods: { + clicked(fn) { + fn(); + this.close(); + }, + close() { + this.$refs.popup.close(); + } + } +}); +</script> + +<style lang="scss" scoped> +@keyframes blink { + 0% { opacity: 1; } + 30% { opacity: 1; } + 90% { opacity: 0; } +} + +.rrevdjwt { + padding: 8px 0; + + &.left { + > .item { + text-align: left; + } + } + + > .item { + display: block; + padding: 8px 16px; + width: 100%; + box-sizing: border-box; + white-space: nowrap; + font-size: 0.9em; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + color: #fff; + background: var(--accent); + text-decoration: none; + } + + &:active { + color: #fff; + background: var(--accentDarken); + } + + &.label { + pointer-events: none; + font-size: 0.7em; + padding-bottom: 4px; + + > span { + opacity: 0.7; + } + } + + > [data-icon] { + margin-right: 4px; + width: 20px; + } + + > .avatar { + margin-right: 4px; + width: 20px; + height: 20px; + } + + > i { + position: absolute; + top: 5px; + left: 13px; + color: var(--accent); + font-size: 12px; + animation: blink 1s infinite; + } + } + + > .divider { + margin: 8px 0; + height: 1px; + background: var(--divider); + } +} +</style> diff --git a/src/client/app/common/views/components/mfm.ts b/src/client/components/mfm.ts index 561c3d8e30..932beb907f 100644 --- a/src/client/app/common/views/components/mfm.ts +++ b/src/client/components/mfm.ts @@ -1,20 +1,13 @@ import Vue, { VNode } from 'vue'; -import { length } from 'stringz'; -import { MfmForest } from '../../../../../mfm/types'; -import { parse, parsePlain } from '../../../../../mfm/parse'; +import { MfmForest } from '../../mfm/types'; +import { parse, parsePlain } from '../../mfm/parse'; import MkUrl from './url.vue'; import MkMention from './mention.vue'; -import { concat, sum } from '../../../../../prelude/array'; +import { concat } from '../../prelude/array'; import MkFormula from './formula.vue'; import MkCode from './code.vue'; import MkGoogle from './google.vue'; -import { host } from '../../../config'; -import { preorderF, countNodesF } from '../../../../../prelude/tree'; - -function sumTextsLength(ts: MfmForest): number { - const textNodes = preorderF(ts).filter(n => n.type === 'text'); - return sum(textNodes.map(x => length(x.props.text))); -} +import { host } from '../config'; export default Vue.component('misskey-flavored-markdown', { props: { @@ -52,9 +45,6 @@ export default Vue.component('misskey-flavored-markdown', { const ast = (this.plain ? parsePlain : parse)(this.text); - let bigCount = 0; - let motionCount = 0; - const genEl = (ast: MfmForest) => concat(ast.map((token): VNode[] => { switch (token.node.type) { case 'text': { @@ -87,14 +77,11 @@ export default Vue.component('misskey-flavored-markdown', { } case 'big': { - bigCount++; - const isLong = sumTextsLength(token.children) > 15 || countNodesF(token.children) > 5; - const isMany = bigCount > 3; return (createElement as any)('strong', { attrs: { - style: `display: inline-block; font-size: ${ isMany ? '100%' : '150%' };` + style: `display: inline-block; font-size: 150% };` }, - directives: [this.$store.state.settings.disableAnimatedMfm || isLong || isMany ? {} : { + directives: [this.$store.state.settings.disableAnimatedMfm ? {} : { name: 'animate-css', value: { classes: 'tada', iteration: 'infinite' } }] @@ -118,14 +105,11 @@ export default Vue.component('misskey-flavored-markdown', { } case 'motion': { - motionCount++; - const isLong = sumTextsLength(token.children) > 15 || countNodesF(token.children) > 5; - const isMany = motionCount > 5; return (createElement as any)('span', { attrs: { style: 'display: inline-block;' }, - directives: [this.$store.state.settings.disableAnimatedMfm || isLong || isMany ? {} : { + directives: [this.$store.state.settings.disableAnimatedMfm ? {} : { name: 'animate-css', value: { classes: 'rubberBand', iteration: 'infinite' } }] @@ -133,14 +117,11 @@ export default Vue.component('misskey-flavored-markdown', { } case 'spin': { - motionCount++; - const isLong = sumTextsLength(token.children) > 10 || countNodesF(token.children) > 5; - const isMany = motionCount > 5; const direction = token.node.props.attr == 'left' ? 'reverse' : token.node.props.attr == 'alternate' ? 'alternate' : 'normal'; - const style = (this.$store.state.settings.disableAnimatedMfm || isLong || isMany) + const style = (this.$store.state.settings.disableAnimatedMfm) ? '' : `animation: spin 1.5s linear infinite; animation-direction: ${direction};`; return (createElement as any)('span', { @@ -151,12 +132,9 @@ export default Vue.component('misskey-flavored-markdown', { } case 'jump': { - motionCount++; - const isLong = sumTextsLength(token.children) > 30 || countNodesF(token.children) > 5; - const isMany = motionCount > 5; return (createElement as any)('span', { attrs: { - style: (this.$store.state.settings.disableAnimatedMfm || isLong || isMany) ? 'display: inline-block;' : 'display: inline-block; animation: jump 0.75s linear infinite;' + style: (this.$store.state.settings.disableAnimatedMfm) ? 'display: inline-block;' : 'display: inline-block; animation: jump 0.75s linear infinite;' }, }, genEl(token.children)); } @@ -177,7 +155,7 @@ export default Vue.component('misskey-flavored-markdown', { rel: 'nofollow noopener', }, attrs: { - style: 'color:var(--mfmUrl);' + style: 'color:var(--link);' } })]; } @@ -190,7 +168,7 @@ export default Vue.component('misskey-flavored-markdown', { rel: 'nofollow noopener', target: '_blank', title: token.node.props.url, - style: 'color:var(--mfmLink);' + style: 'color:var(--link);' } }, genEl(token.children))]; } @@ -210,7 +188,7 @@ export default Vue.component('misskey-flavored-markdown', { key: Math.random(), attrs: { to: this.isNote ? `/tags/${encodeURIComponent(token.node.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.node.props.hashtag)}`, - style: 'color:var(--mfmHashtag);' + style: 'color:var(--hashtag);' } }, `#${token.node.props.hashtag}`)]; } diff --git a/src/client/components/misskey-flavored-markdown.vue b/src/client/components/misskey-flavored-markdown.vue new file mode 100644 index 0000000000..c8eee8c126 --- /dev/null +++ b/src/client/components/misskey-flavored-markdown.vue @@ -0,0 +1,35 @@ +<template> +<mfm-core v-bind="$attrs" class="havbbuyv" :class="{ nowrap: $attrs['nowrap'] }" v-once/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MfmCore from './mfm'; + +export default Vue.extend({ + components: { + MfmCore + } +}); +</script> + +<style lang="scss" scoped> +.havbbuyv { + white-space: pre-wrap; + + &.nowrap { + white-space: pre; + word-wrap: normal; // https://codeday.me/jp/qa/20190424/690106.html + overflow: hidden; + text-overflow: ellipsis; + } + + ::v-deep .quote { + display: block; + margin: 8px; + padding: 6px 0 6px 12px; + color: var(--mfmQuote); + border-left: solid 3px var(--mfmQuoteLine); + } +} +</style> diff --git a/src/client/components/modal.vue b/src/client/components/modal.vue new file mode 100644 index 0000000000..b7e6a336d7 --- /dev/null +++ b/src/client/components/modal.vue @@ -0,0 +1,84 @@ +<template> +<div class="mk-modal"> + <transition name="bg-fade" appear> + <div class="bg" ref="bg" v-if="show" @click="close()"></div> + </transition> + <transition name="modal" appear @after-leave="() => { $emit('closed'); destroyDom(); }"> + <div class="content" ref="content" v-if="show" @click.self="close()"><slot></slot></div> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + }, + data() { + return { + show: true, + }; + }, + methods: { + close() { + this.show = false; + (this.$refs.bg as any).style.pointerEvents = 'none'; + (this.$refs.content as any).style.pointerEvents = 'none'; + } + } +}); +</script> + +<style lang="scss" scoped> +.modal-enter-active, .modal-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.modal-enter, .modal-leave-to { + opacity: 0; + transform: scale(0.9); +} + +.bg-fade-enter-active, .bg-fade-leave-active { + transition: opacity 0.3s !important; +} +.bg-fade-enter, .bg-fade-leave-to { + opacity: 0; +} + +.mk-modal { + > .bg { + position: fixed; + top: 0; + left: 0; + z-index: 10000; + width: 100%; + height: 100%; + background: var(--modalBg) + } + + > .content { + position: fixed; + z-index: 10000; + top: 0; + bottom: 0; + left: 0; + right: 0; + max-width: calc(100% - 16px); + max-height: calc(100% - 16px); + overflow: auto; + margin: auto; + + ::v-deep > * { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: auto; + max-height: 100%; + max-width: 100%; + } + } +} +</style> diff --git a/src/client/components/note-header.vue b/src/client/components/note-header.vue new file mode 100644 index 0000000000..30ecb80834 --- /dev/null +++ b/src/client/components/note-header.vue @@ -0,0 +1,99 @@ +<template> +<header class="kkwtjztg"> + <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-bot" v-if="note.user.isBot">bot</span> + <span class="username"><mk-acct :user="note.user"/></span> + <div class="info"> + <span class="mobile" v-if="note.viaMobile"><fa :icon="faMobileAlt"/></span> + <router-link class="created-at" :to="note | notePage"> + <mk-time :time="note.createdAt"/> + </router-link> + <span class="visibility" v-if="note.visibility != 'public'"> + <fa v-if="note.visibility == 'home'" :icon="faHome"/> + <fa v-if="note.visibility == 'followers'" :icon="faUnlock"/> + <fa v-if="note.visibility == 'specified'" :icon="faEnvelope"/> + </span> + </div> +</header> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faHome, faUnlock, faEnvelope, faMobileAlt } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + props: { + note: { + type: Object, + required: true + }, + }, + + data() { + return { + faHome, faUnlock, faEnvelope, faMobileAlt + }; + } +}); +</script> + +<style lang="scss" scoped> +.kkwtjztg { + display: flex; + align-items: baseline; + white-space: nowrap; + + > .name { + display: block; + margin: 0 .5em 0 0; + padding: 0; + overflow: hidden; + color: var(--noteHeaderName); + font-size: 1em; + font-weight: bold; + text-decoration: none; + text-overflow: ellipsis; + + &:hover { + text-decoration: underline; + } + } + + > .is-bot { + flex-shrink: 0; + align-self: center; + margin: 0 .5em 0 0; + padding: 1px 6px; + font-size: 80%; + color: var(--noteHeaderBadgeFg); + background: var(--noteHeaderBadgeBg); + border-radius: 3px; + } + + > .username { + margin: 0 .5em 0 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--noteHeaderAcct); + } + + > .info { + margin-left: auto; + font-size: 0.9em; + + > * { + color: var(--noteHeaderInfo); + } + + > .mobile { + margin-right: 8px; + } + + > .visibility { + margin-left: 8px; + } + } +} +</style> diff --git a/src/client/app/common/views/components/note-menu.vue b/src/client/components/note-menu.vue index 1dcf58dd36..dd7b062f15 100644 --- a/src/client/app/common/views/components/note-menu.vue +++ b/src/client/components/note-menu.vue @@ -1,18 +1,21 @@ <template> -<div style="position:initial"> - <mk-menu :source="source" :items="items" @closed="closed"/> -</div> +<x-menu :source="source" :items="items" @closed="closed"/> </template> <script lang="ts"> import Vue from 'vue'; -import i18n from '../../../i18n'; -import { url } from '../../../config'; -import copyToClipboard from '../../../common/scripts/copy-to-clipboard'; -import { faCopy, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; +import { faStar, faLink, faThumbtack, faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons'; +import { faCopy, faTrashAlt, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../i18n'; +import { url } from '../config'; +import copyToClipboard from '../scripts/copy-to-clipboard'; +import XMenu from './menu.vue'; export default Vue.extend({ - i18n: i18n('common/views/components/note-menu.vue'), + i18n, + components: { + XMenu + }, props: ['note', 'source'], data() { return { @@ -24,35 +27,27 @@ export default Vue.extend({ items(): any[] { if (this.$store.getters.isSignedIn) { return [{ - icon: 'at', - text: this.$t('mention'), - action: this.mention - }, null, { - icon: 'info-circle', - text: this.$t('detail'), - action: this.detail - }, { icon: faCopy, - text: this.$t('copy-content'), + text: this.$t('copyContent'), action: this.copyContent }, { - icon: 'link', - text: this.$t('copy-link'), + icon: faLink, + text: this.$t('copyLink'), action: this.copyLink }, this.note.uri ? { - icon: 'external-link-square-alt', - text: this.$t('remote'), + icon: faExternalLinkSquareAlt, + text: this.$t('showOnRemote'), action: () => { window.open(this.note.uri, '_blank'); } } : undefined, null, this.isFavorited ? { - icon: 'star', + icon: faStar, text: this.$t('unfavorite'), action: () => this.toggleFavorite(false) } : { - icon: 'star', + icon: faStar, text: this.$t('favorite'), action: () => this.toggleFavorite(true) }, @@ -66,23 +61,18 @@ export default Vue.extend({ action: () => this.toggleWatch(true) } : undefined, this.note.userId == this.$store.state.i.id ? (this.$store.state.i.pinnedNoteIds || []).includes(this.note.id) ? { - icon: 'thumbtack', + icon: faThumbtack, text: this.$t('unpin'), action: () => this.togglePin(false) } : { - icon: 'thumbtack', + icon: faThumbtack, text: this.$t('pin'), action: () => this.togglePin(true) } : undefined, - ...(this.note.userId == this.$store.state.i.id || this.$store.state.i.isAdmin || this.$store.state.i.isModerator ? [ + ...(this.note.userId == this.$store.state.i.id ? [ null, - this.note.userId == this.$store.state.i.id ? { - icon: 'undo-alt', - text: this.$t('delete-and-edit'), - action: this.deleteAndEdit - } : undefined, { - icon: ['far', 'trash-alt'], + icon: faTrashAlt, text: this.$t('delete'), action: this.del }] @@ -91,20 +81,16 @@ export default Vue.extend({ .filter(x => x !== undefined); } else { return [{ - icon: 'info-circle', - text: this.$t('detail'), - action: this.detail - }, { icon: faCopy, - text: this.$t('copy-content'), + text: this.$t('copyContent'), action: this.copyContent }, { - icon: 'link', - text: this.$t('copy-link'), + icon: faLink, + text: this.$t('copyLink'), action: this.copyLink }, this.note.uri ? { - icon: 'external-link-square-alt', - text: this.$t('remote'), + icon: faExternalLinkSquareAlt, + text: this.$t('showOnRemote'), action: () => { window.open(this.note.uri, '_blank'); } @@ -124,19 +110,11 @@ export default Vue.extend({ }, methods: { - mention() { - this.$post({ mention: this.note.user }); - }, - - detail() { - this.$router.push(`/notes/${this.note.id}`); - }, - copyContent() { copyToClipboard(this.note.text); this.$root.dialog({ type: 'success', - splash: true + iconOnly: true, autoClose: true }); }, @@ -144,7 +122,7 @@ export default Vue.extend({ copyToClipboard(`${url}/notes/${this.note.id}`); this.$root.dialog({ type: 'success', - splash: true + iconOnly: true, autoClose: true }); }, @@ -154,14 +132,15 @@ export default Vue.extend({ }).then(() => { this.$root.dialog({ type: 'success', - splash: true + iconOnly: true, autoClose: true }); + this.$emit('closed'); this.destroyDom(); }).catch(e => { if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { this.$root.dialog({ type: 'error', - text: this.$t('pin-limit-exceeded') + text: this.$t('pinLimitExceeded') }); } }); @@ -170,7 +149,7 @@ export default Vue.extend({ del() { this.$root.dialog({ type: 'warning', - text: this.$t('delete-confirm'), + text: this.$t('noteDeleteConfirm'), showCancelButton: true }).then(({ canceled }) => { if (canceled) return; @@ -178,38 +157,21 @@ export default Vue.extend({ this.$root.api('notes/delete', { noteId: this.note.id }).then(() => { + this.$emit('closed'); this.destroyDom(); }); }); }, - deleteAndEdit() { - this.$root.dialog({ - type: 'warning', - text: this.$t('delete-and-edit-confirm'), - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) return; - this.$root.api('notes/delete', { - noteId: this.note.id - }).then(() => { - this.destroyDom(); - }); - this.$post({ - initialNote: this.note, - reply: this.note.reply, - }); - }); - }, - toggleFavorite(favorite: boolean) { this.$root.api(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { noteId: this.note.id }).then(() => { this.$root.dialog({ type: 'success', - splash: true + iconOnly: true, autoClose: true }); + this.$emit('closed'); this.destroyDom(); }); }, @@ -220,7 +182,7 @@ export default Vue.extend({ }).then(() => { this.$root.dialog({ type: 'success', - splash: true + iconOnly: true, autoClose: true }); this.destroyDom(); }); diff --git a/src/client/components/note-preview.vue b/src/client/components/note-preview.vue new file mode 100644 index 0000000000..17ff5be868 --- /dev/null +++ b/src/client/components/note-preview.vue @@ -0,0 +1,121 @@ +<template> +<div class="yohlumlkhizgfkvvscwfcrcggkotpvry"> + <mk-avatar class="avatar" :user="note.user"/> + <div class="main"> + <x-note-header class="header" :note="note" :mini="true"/> + <div class="body"> + <p v-if="note.cw != null" class="cw"> + <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> + <x-cw-button v-model="showContent" :note="note"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <x-sub-note-content class="text" :note="note"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNoteHeader from './note-header.vue'; +import XSubNoteContent from './sub-note-content.vue'; +import XCwButton from './cw-button.vue'; + +export default Vue.extend({ + components: { + XNoteHeader, + XSubNoteContent, + XCwButton, + }, + + props: { + note: { + type: Object, + required: true + } + }, + + data() { + return { + showContent: false + }; + } +}); +</script> + +<style lang="scss" scoped> +.yohlumlkhizgfkvvscwfcrcggkotpvry { + display: flex; + margin: 0; + padding: 0; + overflow: hidden; + font-size: 10px; + + @media (min-width: 350px) { + font-size: 12px; + } + + @media (min-width: 500px) { + font-size: 14px; + } + + > .avatar { + + @media (min-width: 350px) { + margin: 0 10px 0 0; + width: 44px; + height: 44px; + } + + @media (min-width: 500px) { + margin: 0 12px 0 0; + width: 48px; + height: 48px; + } + } + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 10px 0 0; + width: 40px; + height: 40px; + border-radius: 8px; + } + + > .main { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + color: var(--noteText); + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + cursor: default; + margin: 0; + padding: 0; + color: var(--subNoteText); + } + } + } + } +} +</style> diff --git a/src/client/components/note.sub.vue b/src/client/components/note.sub.vue new file mode 100644 index 0000000000..7f6f972896 --- /dev/null +++ b/src/client/components/note.sub.vue @@ -0,0 +1,108 @@ +<template> +<div class="zlrxdaqttccpwhpaagdmkawtzklsccam"> + <mk-avatar class="avatar" :user="note.user"/> + <div class="main"> + <x-note-header class="header" :note="note" :mini="true"/> + <div class="body"> + <p v-if="note.cw != null" class="cw"> + <mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis" /> + <x-cw-button v-model="showContent" :note="note"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <x-sub-note-content class="text" :note="note"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNoteHeader from './note-header.vue'; +import XSubNoteContent from './sub-note-content.vue'; +import XCwButton from './cw-button.vue'; + +export default Vue.extend({ + components: { + XNoteHeader, + XSubNoteContent, + XCwButton, + }, + + props: { + note: { + type: Object, + required: true + }, + // TODO + truncate: { + type: Boolean, + default: true + } + }, + + inject: { + narrow: { + default: false + } + }, + + data() { + return { + showContent: false + }; + } +}); +</script> + +<style lang="scss" scoped> +.zlrxdaqttccpwhpaagdmkawtzklsccam { + display: flex; + padding: 16px 32px; + font-size: 0.9em; + background: rgba(0, 0, 0, 0.03); + + @media (max-width: 450px) { + padding: 14px 16px; + } + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 8px 0 0; + width: 38px; + height: 38px; + border-radius: 8px; + } + + > .main { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + margin: 0; + padding: 0; + } + } + } + } +} +</style> diff --git a/src/client/components/note.vue b/src/client/components/note.vue new file mode 100644 index 0000000000..8b3fa61a65 --- /dev/null +++ b/src/client/components/note.vue @@ -0,0 +1,729 @@ +<template> +<div + class="note _panel" + v-show="appearNote.deletedAt == null && !hideThisNote" + :tabindex="appearNote.deletedAt == null ? '-1' : null" + :class="{ renote: isRenote }" + v-hotkey="keymap" + v-size="[{ max: 500 }, { max: 450 }, { max: 350 }, { max: 300 }]" +> + <x-sub v-for="note in conversation" :key="note.id" :note="note"/> + <x-sub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> + <div class="pinned" v-if="pinned"><fa :icon="faThumbtack"/> {{ $t('pinnedNote') }}</div> + <div class="renote" v-if="isRenote"> + <mk-avatar class="avatar" :user="note.user"/> + <fa :icon="faRetweet"/> + <i18n path="renotedBy" 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"> + <mk-time :time="note.createdAt"/> + <span class="visibility" v-if="note.visibility != 'public'"> + <fa v-if="note.visibility == 'home'" :icon="faHome"/> + <fa v-if="note.visibility == 'followers'" :icon="faUnlock"/> + <fa v-if="note.visibility == 'specified'" :icon="faEnvelope"/> + </span> + </div> + </div> + <article class="article"> + <mk-avatar class="avatar" :user="appearNote.user"/> + <div class="main"> + <x-note-header class="header" :note="appearNote" :mini="true"/> + <div class="body" v-if="appearNote.deletedAt == null"> + <p v-if="appearNote.cw != null" class="cw"> + <mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" /> + <x-cw-button v-model="showContent" :note="appearNote"/> + </p> + <div class="content" v-show="appearNote.cw == null || showContent"> + <div class="text"> + <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $t('private') }})</span> + <router-link class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><fa :icon="faReply"/></router-link> + <mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/> + <a class="rp" v-if="appearNote.renote != null">RN:</a> + </div> + <div class="files" v-if="appearNote.files.length > 0"> + <x-media-list :media-list="appearNote.files"/> + </div> + <x-poll v-if="appearNote.poll" :note="appearNote" ref="pollViewer"/> + <x-url-preview v-for="url in urls" :url="url" :key="url" :compact="true" class="url-preview"/> + <div class="renote" v-if="appearNote.renote"><x-note-preview :note="appearNote.renote"/></div> + </div> + </div> + <footer v-if="appearNote.deletedAt == null" class="footer"> + <x-reactions-viewer :note="appearNote" ref="reactionsViewer"/> + <button @click="reply()" class="button _button"> + <template v-if="appearNote.reply"><fa :icon="faReplyAll"/></template> + <template v-else><fa :icon="faReply"/></template> + <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> + </button> + <button v-if="['public', 'home'].includes(appearNote.visibility)" @click="renote()" class="button _button" ref="renoteButton"> + <fa :icon="faRetweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> + </button> + <button v-else class="button _button"> + <fa :icon="faBan"/> + </button> + <button v-if="!isMyNote && appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton"> + <fa :icon="faPlus"/> + </button> + <button v-if="!isMyNote && appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton"> + <fa :icon="faMinus"/> + </button> + <button class="button _button" @click="menu()" ref="menuButton"> + <fa :icon="faEllipsisH"/> + </button> + </footer> + <div class="deleted" v-if="appearNote.deletedAt != null">{{ $t('deleted') }}</div> + </div> + </article> + <x-sub v-for="note in replies" :key="note.id" :note="note"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan } from '@fortawesome/free-solid-svg-icons'; +import { parse } from '../../mfm/parse'; +import { sum, unique } from '../../prelude/array'; +import i18n from '../i18n'; +import XSub from './note.sub.vue'; +import XNoteHeader from './note-header.vue'; +import XNotePreview from './note-preview.vue'; +import XReactionsViewer from './reactions-viewer.vue'; +import XMediaList from './media-list.vue'; +import XCwButton from './cw-button.vue'; +import XPoll from './poll.vue'; +import XUrlPreview from './url-preview.vue'; +import MkNoteMenu from './note-menu.vue'; +import MkReactionPicker from './reaction-picker.vue'; +import MkRenotePicker from './renote-picker.vue'; +import pleaseLogin from '../scripts/please-login'; + +function focus(el, fn) { + const target = fn(el); + if (target) { + if (target.hasAttribute('tabindex')) { + target.focus(); + } else { + focus(target, fn); + } + } +} + +export default Vue.extend({ + i18n, + + components: { + XSub, + XNoteHeader, + XNotePreview, + XReactionsViewer, + XMediaList, + XCwButton, + XPoll, + XUrlPreview, + }, + + props: { + note: { + type: Object, + required: true + }, + detail: { + type: Boolean, + required: false, + default: false + }, + pinned: { + type: Boolean, + required: false, + default: false + }, + }, + + data() { + return { + connection: null, + conversation: [], + replies: [], + showContent: false, + hideThisNote: false, + openingMenu: false, + faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan + }; + }, + + computed: { + keymap(): any { + return { + 'r': () => this.reply(true), + 'e|a|plus': () => this.react(true), + 'q': () => this.renote(true), + 'f|b': this.favorite, + 'delete|ctrl+d': this.del, + 'ctrl+q': this.renoteDirectly, + 'up|k|shift+tab': this.focusBefore, + 'down|j|tab': this.focusAfter, + 'esc': this.blur, + 'm|o': () => this.menu(true), + 's': this.toggleShowContent, + '1': () => this.reactDirectly(this.$store.state.settings.reactions[0]), + '2': () => this.reactDirectly(this.$store.state.settings.reactions[1]), + '3': () => this.reactDirectly(this.$store.state.settings.reactions[2]), + '4': () => this.reactDirectly(this.$store.state.settings.reactions[3]), + '5': () => this.reactDirectly(this.$store.state.settings.reactions[4]), + '6': () => this.reactDirectly(this.$store.state.settings.reactions[5]), + '7': () => this.reactDirectly(this.$store.state.settings.reactions[6]), + '8': () => this.reactDirectly(this.$store.state.settings.reactions[7]), + '9': () => this.reactDirectly(this.$store.state.settings.reactions[8]), + '0': () => this.reactDirectly(this.$store.state.settings.reactions[9]), + }; + }, + + isRenote(): boolean { + return (this.note.renote && + this.note.text == null && + this.note.fileIds.length == 0 && + this.note.poll == null); + }, + + appearNote(): any { + return this.isRenote ? this.note.renote : this.note; + }, + + isMyNote(): boolean { + return this.$store.getters.isSignedIn && (this.$store.state.i.id === this.appearNote.userId); + }, + + reactionsCount(): number { + return this.appearNote.reactions + ? sum(Object.values(this.appearNote.reactions)) + : 0; + }, + + title(): string { + return ''; + }, + + urls(): string[] { + if (this.appearNote.text) { + const ast = parse(this.appearNote.text); + // TODO: 再帰的にURL要素がないか調べる + const urls = unique(ast + .filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent)) + .map(t => t.node.props.url)); + + // unique without hash + // [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ] + const removeHash = x => x.replace(/#[^#]*$/, ''); + + return urls.reduce((array, url) => { + const removed = removeHash(url); + if (!array.map(x => removeHash(x)).includes(removed)) array.push(url); + return array; + }, []); + } else { + return null; + } + } + }, + + created() { + if (this.$store.getters.isSignedIn) { + this.connection = this.$root.stream; + } + + if (this.detail) { + this.$root.api('notes/children', { + noteId: this.appearNote.id, + limit: 30 + }).then(replies => { + this.replies = replies; + }); + + if (this.appearNote.replyId) { + this.$root.api('notes/conversation', { + noteId: this.appearNote.replyId + }).then(conversation => { + this.conversation = conversation.reverse(); + }); + } + } + }, + + mounted() { + this.capture(true); + + if (this.$store.getters.isSignedIn) { + this.connection.on('_connected_', this.onStreamConnected); + } + }, + + beforeDestroy() { + this.decapture(true); + + if (this.$store.getters.isSignedIn) { + this.connection.off('_connected_', this.onStreamConnected); + } + }, + + methods: { + capture(withHandler = false) { + if (this.$store.getters.isSignedIn) { + this.connection.send('sn', { id: this.appearNote.id }); + if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated); + } + }, + + decapture(withHandler = false) { + if (this.$store.getters.isSignedIn) { + this.connection.send('un', { + id: this.appearNote.id + }); + if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated); + } + }, + + onStreamConnected() { + this.capture(); + }, + + onStreamNoteUpdated(data) { + const { type, id, body } = data; + + if (id !== this.appearNote.id) return; + + switch (type) { + case 'reacted': { + const reaction = body.reaction; + + if (this.appearNote.reactions == null) { + Vue.set(this.appearNote, 'reactions', {}); + } + + if (this.appearNote.reactions[reaction] == null) { + Vue.set(this.appearNote.reactions, reaction, 0); + } + + // Increment the count + this.appearNote.reactions[reaction]++; + + if (body.userId == this.$store.state.i.id) { + Vue.set(this.appearNote, 'myReaction', reaction); + } + break; + } + + case 'unreacted': { + const reaction = body.reaction; + + if (this.appearNote.reactions == null) { + return; + } + + if (this.appearNote.reactions[reaction] == null) { + return; + } + + // Decrement the count + if (this.appearNote.reactions[reaction] > 0) this.appearNote.reactions[reaction]--; + + if (body.userId == this.$store.state.i.id) { + Vue.set(this.appearNote, 'myReaction', null); + } + break; + } + + case 'pollVoted': { + const choice = body.choice; + this.appearNote.poll.choices[choice].votes++; + if (body.userId == this.$store.state.i.id) { + Vue.set(this.appearNote.poll.choices[choice], 'isVoted', true); + } + break; + } + + case 'deleted': { + Vue.set(this.appearNote, 'deletedAt', body.deletedAt); + Vue.set(this.appearNote, 'renote', null); + this.appearNote.text = null; + this.appearNote.fileIds = []; + this.appearNote.poll = null; + this.appearNote.cw = null; + break; + } + } + }, + + reply(viaKeyboard = false) { + pleaseLogin(this.$root); + this.$root.post({ + reply: this.appearNote, + animation: !viaKeyboard, + }, () => { + this.focus(); + }); + }, + + renote() { + pleaseLogin(this.$root); + this.blur(); + this.$root.new(MkRenotePicker, { + source: this.$refs.renoteButton, + note: this.appearNote, + }).$once('closed', this.focus); + }, + + renoteDirectly() { + (this as any).$root.api('notes/create', { + renoteId: this.appearNote.id + }); + }, + + react(viaKeyboard = false) { + pleaseLogin(this.$root); + this.blur(); + const picker = this.$root.new(MkReactionPicker, { + source: this.$refs.reactButton, + showFocus: viaKeyboard, + }); + picker.$once('chosen', reaction => { + this.$root.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }).then(() => { + picker.close(); + }); + }); + picker.$once('closed', this.focus); + }, + + reactDirectly(reaction) { + this.$root.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + }, + + undoReact(note) { + const oldReaction = note.myReaction; + if (!oldReaction) return; + this.$root.api('notes/reactions/delete', { + noteId: note.id + }); + }, + + favorite() { + pleaseLogin(this.$root); + this.$root.api('notes/favorites/create', { + noteId: this.appearNote.id + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }); + }, + + del() { + this.$root.dialog({ + type: 'warning', + text: this.$t('noteDeleteConfirm'), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + this.$root.api('notes/delete', { + noteId: this.appearNote.id + }); + }); + }, + + menu(viaKeyboard = false) { + if (this.openingMenu) return; + this.openingMenu = true; + const w = this.$root.new(MkNoteMenu, { + source: this.$refs.menuButton, + note: this.appearNote, + animation: !viaKeyboard + }).$once('closed', () => { + this.openingMenu = false; + this.focus(); + }); + }, + + toggleShowContent() { + this.showContent = !this.showContent; + }, + + focus() { + this.$el.focus(); + }, + + blur() { + this.$el.blur(); + }, + + focusBefore() { + focus(this.$el, e => e.previousElementSibling); + }, + + focusAfter() { + focus(this.$el, e => e.nextElementSibling); + } + } +}); +</script> + +<style lang="scss" scoped> +.note { + position: relative; + transition: box-shadow 0.1s ease; + + &.max-width_500px { + font-size: 0.9em; + } + + &.max-width_450px { + > .renote { + padding: 8px 16px 0 16px; + } + + > .article { + padding: 14px 16px 9px; + + > .avatar { + margin: 0 10px 8px 0; + width: 50px; + height: 50px; + } + } + } + + &.max-width_350px { + > .article { + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 18px; + } + } + } + } + } + } + + &.max-width_300px { + font-size: 0.825em; + + > .article { + > .avatar { + width: 44px; + height: 44px; + } + + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 12px; + } + } + } + } + } + } + + &:focus { + outline: none; + box-shadow: 0 0 0 3px var(--focus); + } + + &:hover > .article > .main > .footer > .button { + opacity: 1; + } + + > *:first-child { + border-radius: var(--radius) var(--radius) 0 0; + } + + > *:last-child { + border-radius: 0 0 var(--radius) var(--radius); + } + + > .pinned { + padding: 16px 32px 8px 32px; + line-height: 24px; + font-size: 90%; + white-space: pre; + color: #d28a3f; + + @media (max-width: 450px) { + padding: 8px 16px 0 16px; + } + + > [data-icon] { + margin-right: 4px; + } + } + + > .pinned + .article { + padding-top: 8px; + } + + > .renote { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 28px; + white-space: pre; + color: var(--renote); + + > .avatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; + border-radius: 6px; + } + + > [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; + + > .mk-time { + flex-shrink: 0; + } + + > .visibility { + margin-left: 8px; + + [data-icon] { + margin-right: 0; + } + } + } + } + + > .renote + .article { + padding-top: 8px; + } + + > .article { + display: flex; + padding: 28px 32px 18px; + + > .avatar { + flex-shrink: 0; + display: block; + //position: sticky; + //top: 72px; + margin: 0 14px 8px 0; + width: 58px; + height: 58px; + } + + > .main { + flex: 1; + min-width: 0; + + > .body { + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + overflow-wrap: break-word; + + > .reply { + color: var(--accent); + margin-right: 0.5em; + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + } + + > .url-preview { + margin-top: 8px; + } + + > .mk-poll { + font-size: 80%; + } + + > .renote { + padding: 8px 0; + + > * { + padding: 16px; + border: dashed 1px var(--renote); + border-radius: 8px; + } + } + } + } + + > .footer { + > .button { + margin: 0; + padding: 8px; + opacity: 0.7; + + &:not(:last-child) { + margin-right: 28px; + } + + &:hover { + color: var(--mkykhqkw); + } + + > .count { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; + } + + &.reacted { + color: var(--accent); + } + } + } + + > .deleted { + opacity: 0.7; + } + } + } +} +</style> diff --git a/src/client/components/notes.vue b/src/client/components/notes.vue new file mode 100644 index 0000000000..7cf2aa2b02 --- /dev/null +++ b/src/client/components/notes.vue @@ -0,0 +1,144 @@ +<template> +<div class="mk-notes" v-size="[{ max: 500 }]"> + <div class="empty" v-if="empty">{{ $t('noNotes') }}</div> + + <mk-error v-if="error" @retry="init()"/> + + <x-list ref="notes" class="notes" :items="notes" v-slot="{ item: note, i }"> + <x-note :note="note" :detail="detail" :key="note.id" :data-index="i"/> + </x-list> + + <footer v-if="more"> + <button @click="fetchMore()" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" class="_buttonPrimary"> + <template v-if="!moreFetching">{{ $t('loadMore') }}</template> + <template v-if="moreFetching"><fa :icon="faSpinner" pulse fixed-width/></template> + </button> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import paging from '../scripts/paging'; +import XNote from './note.vue'; +import XList from './date-separated-list.vue'; +import getUserName from '../../misc/get-user-name'; +import getNoteSummary from '../../misc/get-note-summary'; + +export default Vue.extend({ + i18n, + + components: { + XNote, XList + }, + + mixins: [ + paging({ + onPrepend: (self, note) => { + // タブが非表示なら通知 + if (document.hidden) { + if ('Notification' in window && Notification.permission === 'granted') { + new Notification(getUserName(note.user), { + body: getNoteSummary(note), + icon: note.user.avatarUrl, + tag: 'newNote' + }); + } + } + }, + + before: (self) => { + self.$emit('before'); + }, + + after: (self, e) => { + self.$emit('after', e); + } + }), + ], + + props: { + pagination: { + required: true + }, + + detail: { + type: Boolean, + required: false, + default: false + }, + + extract: { + required: false + } + }, + + data() { + return { + faSpinner + }; + }, + + computed: { + notes(): any[] { + return this.extract ? this.extract(this.items) : this.items; + }, + }, + + methods: { + focus() { + this.$refs.notes.focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-notes { + > .empty { + margin: 0 auto; + padding: 32px; + text-align: center; + background: rgba(0, 0, 0, 0.3); + color: #fff; + -webkit-backdrop-filter: blur(16px); + backdrop-filter: blur(16px); + border-radius: 6px; + } + + > .notes { + > ::v-deep * { + margin-bottom: var(--marginFull); + } + } + + &.max-width_500px { + > .notes { + > ::v-deep * { + margin-bottom: var(--marginHalf); + } + } + } + + > footer { + text-align: center; + + &:empty { + display: none; + } + + > button { + margin: 0; + padding: 16px; + width: 100%; + border-radius: var(--radius); + + &:disabled { + opacity: 0.7; + } + } + } +} +</style> diff --git a/src/client/components/notification.vue b/src/client/components/notification.vue new file mode 100644 index 0000000000..e325f0adb6 --- /dev/null +++ b/src/client/components/notification.vue @@ -0,0 +1,219 @@ +<template> +<div class="mk-notification" :class="notification.type"> + <div class="head"> + <mk-avatar class="avatar" :user="notification.user"/> + <div class="icon" :class="notification.type"> + <fa :icon="faPlus" v-if="notification.type === 'follow'"/> + <fa :icon="faClock" v-if="notification.type === 'receiveFollowRequest'"/> + <fa :icon="faCheck" v-if="notification.type === 'followRequestAccepted'"/> + <fa :icon="faRetweet" v-if="notification.type === 'renote'"/> + <fa :icon="faReply" v-if="notification.type === 'reply'"/> + <fa :icon="faAt" v-if="notification.type === 'mention'"/> + <fa :icon="faQuoteLeft" v-if="notification.type === 'quote'"/> + <x-reaction-icon v-if="notification.type === 'reaction'" :reaction="notification.reaction" :no-style="true"/> + </div> + </div> + <div class="tail"> + <header> + <router-link class="name" :to="notification.user | userPage" v-user-preview="notification.user.id"><mk-user-name :user="notification.user"/></router-link> + <mk-time :time="notification.createdAt" v-if="withTime"/> + </header> + <router-link v-if="notification.type === 'reaction'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <fa :icon="faQuoteLeft"/> + <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.emojis"/> + <fa :icon="faQuoteRight"/> + </router-link> + <router-link v-if="notification.type === 'renote'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note.renote)"> + <fa :icon="faQuoteLeft"/> + <mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.renote.emojis"/> + <fa :icon="faQuoteRight"/> + </router-link> + <router-link v-if="notification.type === 'reply'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.emojis"/> + </router-link> + <router-link v-if="notification.type === 'mention'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.emojis"/> + </router-link> + <router-link v-if="notification.type === 'quote'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.emojis"/> + </router-link> + <span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ $t('youGotNewFollower') }}</span> + <span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ $t('followRequestAccepted') }}</span> + <span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ $t('receiveFollowRequest') }}<div v-if="!nowrap && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ $t('accept') }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ $t('reject') }}</button></div></span> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faPlus, faQuoteLeft, faQuoteRight, faRetweet, faReply, faAt, faCheck } from '@fortawesome/free-solid-svg-icons'; +import { faClock } from '@fortawesome/free-regular-svg-icons'; +import getNoteSummary from '../../misc/get-note-summary'; +import XReactionIcon from './reaction-icon.vue'; + +export default Vue.extend({ + components: { + XReactionIcon + }, + props: { + notification: { + type: Object, + required: true, + }, + withTime: { + type: Boolean, + required: false, + default: false, + }, + nowrap: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + getNoteSummary, + followRequestDone: false, + faPlus, faQuoteLeft, faQuoteRight, faRetweet, faReply, faAt, faClock, faCheck + }; + }, + methods: { + acceptFollowRequest() { + this.followRequestDone = true; + this.$root.api('following/requests/accept', { userId: this.notification.user.id }); + }, + rejectFollowRequest() { + this.followRequestDone = true; + this.$root.api('following/requests/reject', { userId: this.notification.user.id }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-notification { + position: relative; + box-sizing: border-box; + padding: 16px; + font-size: 0.9em; + overflow-wrap: break-word; + display: flex; + + @media (max-width: 500px) { + padding: 12px; + font-size: 0.8em; + } + + &:after { + content: ""; + display: block; + clear: both; + } + + > .head { + position: sticky; + top: 0; + flex-shrink: 0; + width: 42px; + height: 42px; + margin-right: 8px; + + > .avatar { + display: block; + width: 100%; + height: 100%; + border-radius: 6px; + } + + > .icon { + position: absolute; + z-index: 1; + bottom: -2px; + right: -2px; + width: 20px; + height: 20px; + box-sizing: border-box; + border-radius: 100%; + background: var(--panel); + box-shadow: 0 0 0 3px var(--panel); + font-size: 12px; + pointer-events: none; + + > * { + color: #fff; + width: 100%; + height: 100%; + } + + &.follow, &.followRequestAccepted, &.receiveFollowRequest { + padding: 3px; + background: #36aed2; + } + + &.retweet { + padding: 3px; + background: #36d298; + } + + &.quote { + padding: 3px; + background: #36d298; + } + + &.reply { + padding: 3px; + background: #007aff; + } + + &.mention { + padding: 3px; + background: #88a6b7; + } + } + } + + > .tail { + flex: 1; + min-width: 0; + + > header { + display: flex; + align-items: baseline; + white-space: nowrap; + + > .name { + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + overflow: hidden; + } + + > .mk-time { + margin-left: auto; + font-size: 0.9em; + } + } + + > .text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + > [data-icon] { + vertical-align: super; + font-size: 50%; + opacity: 0.5; + } + + > [data-icon]:first-child { + margin-right: 4px; + } + + > [data-icon]:last-child { + margin-left: 4px; + } + } + } +} +</style> diff --git a/src/client/components/notifications.vue b/src/client/components/notifications.vue new file mode 100644 index 0000000000..ad82913380 --- /dev/null +++ b/src/client/components/notifications.vue @@ -0,0 +1,136 @@ +<template> +<div class="mk-notifications"> + <div class="contents"> + <x-list class="notifications" :items="items" v-slot="{ item: notification, i }"> + <x-notification :notification="notification" :with-time="true" :nowrap="false" class="notification" :key="notification.id" :data-index="i"/> + </x-list> + + <button class="more _button" v-if="more" @click="fetchMore" :disabled="moreFetching"> + <template v-if="!moreFetching">{{ $t('loadMore') }}</template> + <template v-if="moreFetching"><fa :icon="faSpinner" pulse fixed-width/></template> + </button> + + <p class="empty" v-if="empty">{{ $t('noNotifications') }}</p> + + <mk-error v-if="error" @retry="init()"/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import paging from '../scripts/paging'; +import XNotification from './notification.vue'; +import XList from './date-separated-list.vue'; + +export default Vue.extend({ + i18n, + + components: { + XNotification, + XList, + }, + + mixins: [ + paging({}), + ], + + props: { + type: { + type: String, + required: false + }, + wide: { + type: Boolean, + required: false, + default: false + } + }, + + data() { + return { + connection: null, + pagination: { + endpoint: 'i/notifications', + limit: 10, + params: () => ({ + includeTypes: this.type ? [this.type] : undefined + }) + }, + faSpinner + }; + }, + + watch: { + type() { + this.reload(); + } + }, + + mounted() { + this.connection = this.$root.stream.useSharedConnection('main'); + this.connection.on('notification', this.onNotification); + }, + + beforeDestroy() { + this.connection.dispose(); + }, + + methods: { + onNotification(notification) { + // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない + this.$root.stream.send('readNotification', { + id: notification.id + }); + + this.prepend(notification); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-notifications { + > .contents { + overflow: auto; + height: 100%; + padding: 8px 8px 0 8px; + + > .notifications { + > ::v-deep * { + margin-bottom: 8px; + } + + > .notification { + background: var(--panel); + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + } + + > .more { + display: block; + width: 100%; + padding: 16px; + + > [data-icon] { + margin-right: 4px; + } + } + + > .empty { + margin: 0; + padding: 16px; + text-align: center; + color: var(--fg); + } + + > .placeholder { + padding: 32px; + opacity: 0.3; + } + } +} +</style> diff --git a/src/client/components/page-preview.vue b/src/client/components/page-preview.vue new file mode 100644 index 0000000000..5ba226c481 --- /dev/null +++ b/src/client/components/page-preview.vue @@ -0,0 +1,163 @@ +<template> +<router-link :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj" tabindex="-1"> + <div class="thumbnail" v-if="page.eyeCatchingImage" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div> + <article> + <header> + <h1 :title="page.title">{{ page.title }}</h1> + </header> + <p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p> + <footer> + <img class="icon" :src="page.user.avatarUrl"/> + <p>{{ page.user | userName }}</p> + </footer> + </article> +</router-link> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + page: { + type: Object, + required: true + }, + }, +}); +</script> + +<style lang="scss" scoped> +.vhpxefrj { + display: block; + overflow: hidden; + width: 100%; + border: solid var(--lineWidth) var(--urlPreviewBorder); + border-radius: 4px; + overflow: hidden; + + &:hover { + text-decoration: none; + border-color: var(--urlPreviewBorderHover); + } + + > .thumbnail { + position: absolute; + width: 100px; + height: 100%; + background-position: center; + background-size: cover; + display: flex; + justify-content: center; + align-items: center; + + > button { + font-size: 3.5em; + opacity: 0.7; + + &:hover { + font-size: 4em; + opacity: 0.9; + } + } + + & + article { + left: 100px; + width: calc(100% - 100px); + } + } + + > article { + padding: 16px; + + > header { + margin-bottom: 8px; + + > h1 { + margin: 0; + font-size: 1em; + color: var(--urlPreviewTitle); + } + } + + > p { + margin: 0; + color: var(--urlPreviewText); + font-size: 0.8em; + } + + > footer { + margin-top: 8px; + height: 16px; + + > img { + display: inline-block; + width: 16px; + height: 16px; + margin-right: 4px; + vertical-align: top; + } + + > p { + display: inline-block; + margin: 0; + color: var(--urlPreviewInfo); + font-size: 0.8em; + line-height: 16px; + vertical-align: top; + } + } + } + + @media (max-width: 700px) { + > .thumbnail { + position: relative; + width: 100%; + height: 100px; + + & + article { + left: 0; + width: 100%; + } + } + } + + @media (max-width: 550px) { + font-size: 12px; + + > .thumbnail { + height: 80px; + } + + > article { + padding: 12px; + } + } + + @media (max-width: 500px) { + font-size: 10px; + + > .thumbnail { + height: 70px; + } + + > article { + padding: 8px; + + > header { + margin-bottom: 4px; + } + + > footer { + margin-top: 4px; + + > img { + width: 12px; + height: 12px; + } + } + } + } +} + +</style> diff --git a/src/client/app/common/views/components/page/page.block.vue b/src/client/components/page/page.block.vue index 56d1822013..c1d046fa2e 100644 --- a/src/client/app/common/views/components/page/page.block.vue +++ b/src/client/components/page/page.block.vue @@ -22,7 +22,6 @@ export default Vue.extend({ components: { XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton }, - props: { value: { required: true diff --git a/src/client/app/common/views/components/page/page.button.vue b/src/client/components/page/page.button.vue index 87112aca0d..eeb56d5eca 100644 --- a/src/client/app/common/views/components/page/page.button.vue +++ b/src/client/components/page/page.button.vue @@ -1,13 +1,17 @@ <template> <div> - <ui-button class="kudkigyw" @click="click()" :primary="value.primary">{{ script.interpolate(value.text) }}</ui-button> + <mk-button class="kudkigyw" @click="click()" :primary="value.primary">{{ script.interpolate(value.text) }}</mk-button> </div> </template> <script lang="ts"> import Vue from 'vue'; +import MkButton from '../ui/button.vue'; export default Vue.extend({ + components: { + MkButton + }, props: { value: { required: true @@ -16,7 +20,6 @@ export default Vue.extend({ required: true } }, - methods: { click() { if (this.value.action === 'dialog') { @@ -46,10 +49,11 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.kudkigyw - display inline-block - min-width 200px - max-width 450px - margin 8px 0 +<style lang="scss" scoped> +.kudkigyw { + display: inline-block; + min-width: 200px; + max-width: 450px; + margin: 8px 0; +} </style> diff --git a/src/client/app/common/views/components/page/page.counter.vue b/src/client/components/page/page.counter.vue index 8d55319fe9..781a1bd549 100644 --- a/src/client/app/common/views/components/page/page.counter.vue +++ b/src/client/components/page/page.counter.vue @@ -1,13 +1,17 @@ <template> <div> - <ui-button class="llumlmnx" @click="click()">{{ script.interpolate(value.text) }}</ui-button> + <mk-button class="llumlmnx" @click="click()">{{ script.interpolate(value.text) }}</mk-button> </div> </template> <script lang="ts"> import Vue from 'vue'; +import MkButton from '../ui/button.vue'; export default Vue.extend({ + components: { + MkButton + }, props: { value: { required: true @@ -16,20 +20,17 @@ export default Vue.extend({ required: true } }, - data() { return { v: 0, }; }, - watch: { v() { this.script.aiScript.updatePageVar(this.value.name, this.v); this.script.eval(); } }, - methods: { click() { this.v = this.v + (this.value.inc || 1); @@ -38,10 +39,11 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.llumlmnx - display inline-block - min-width 300px - max-width 450px - margin 8px 0 +<style lang="scss" scoped> +.llumlmnx { + display: inline-block; + min-width: 300px; + max-width: 450px; + margin: 8px 0; +} </style> diff --git a/src/client/app/common/views/components/page/page.if.vue b/src/client/components/page/page.if.vue index 417ef0c553..a714a522e8 100644 --- a/src/client/app/common/views/components/page/page.if.vue +++ b/src/client/components/page/page.if.vue @@ -22,9 +22,8 @@ export default Vue.extend({ required: true } }, - beforeCreate() { - this.$options.components.XBlock = require('./page.block.vue').default + this.$options.components.XBlock = require('./page.block.vue').default; }, }); </script> diff --git a/src/client/app/common/views/components/page/page.image.vue b/src/client/components/page/page.image.vue index 1285445eb0..f0d7c7b30f 100644 --- a/src/client/app/common/views/components/page/page.image.vue +++ b/src/client/components/page/page.image.vue @@ -1,6 +1,6 @@ <template> <div class="lzyxtsnt"> - <img v-if="image" :src="image.url"/> + <img v-if="image" :src="image.url" alt=""/> </div> </template> @@ -16,21 +16,21 @@ export default Vue.extend({ required: true }, }, - data() { return { image: null, }; }, - created() { this.image = this.page.attachedFiles.find(x => x.id === this.value.fileId); } }); </script> -<style lang="stylus" scoped> -.lzyxtsnt - > img - max-width 100% +<style lang="scss" scoped> +.lzyxtsnt { + > img { + max-width: 100%; + } +} </style> diff --git a/src/client/app/common/views/components/page/page.number-input.vue b/src/client/components/page/page.number-input.vue index 31da37330a..9ee2730fac 100644 --- a/src/client/app/common/views/components/page/page.number-input.vue +++ b/src/client/components/page/page.number-input.vue @@ -1,13 +1,17 @@ <template> <div> - <ui-input class="kudkigyw" v-model="v" type="number">{{ script.interpolate(value.text) }}</ui-input> + <mk-input class="kudkigyw" v-model="v" type="number">{{ script.interpolate(value.text) }}</mk-input> </div> </template> <script lang="ts"> import Vue from 'vue'; +import MkInput from '../ui/input.vue'; export default Vue.extend({ + components: { + MkInput + }, props: { value: { required: true @@ -16,13 +20,11 @@ export default Vue.extend({ required: true } }, - data() { return { v: this.value.default, }; }, - watch: { v() { this.script.aiScript.updatePageVar(this.value.name, this.v); @@ -32,10 +34,11 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.kudkigyw - display inline-block - min-width 300px - max-width 450px - margin 8px 0 +<style lang="scss" scoped> +.kudkigyw { + display: inline-block; + min-width: 300px; + max-width: 450px; + margin: 8px 0; +} </style> diff --git a/src/client/components/page/page.post.vue b/src/client/components/page/page.post.vue new file mode 100644 index 0000000000..010a96c855 --- /dev/null +++ b/src/client/components/page/page.post.vue @@ -0,0 +1,75 @@ +<template> +<div class="ngbfujlo"> + <mk-textarea class="textarea" :value="text" readonly></mk-textarea> + <mk-button primary @click="post()" :disabled="posting || posted">{{ posted ? $t('posted-from-post-form') : $t('post-from-post-form') }}</mk-button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../i18n'; +import MkTextarea from '../ui/textarea.vue'; +import MkButton from '../ui/button.vue'; + +export default Vue.extend({ + i18n, + components: { + MkTextarea, + MkButton, + }, + props: { + value: { + required: true + }, + script: { + required: true + } + }, + data() { + return { + text: this.script.interpolate(this.value.text), + posted: false, + posting: false, + }; + }, + watch: { + 'script.vars': { + handler() { + this.text = this.script.interpolate(this.value.text); + }, + deep: true + } + }, + methods: { + post() { + this.posting = true; + this.$root.api('notes/create', { + text: this.text, + }).then(() => { + this.posted = true; + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.ngbfujlo { + padding: 0 32px 32px 32px; + border: solid 2px var(--divider); + border-radius: 6px; + + @media (max-width: 600px) { + padding: 0 16px 16px 16px; + + > .textarea { + margin-top: 16px; + margin-bottom: 16px; + } + } +} +</style> diff --git a/src/client/app/common/views/components/page/page.radio-button.vue b/src/client/components/page/page.radio-button.vue index 27c11bebad..fda0a03927 100644 --- a/src/client/app/common/views/components/page/page.radio-button.vue +++ b/src/client/components/page/page.radio-button.vue @@ -1,14 +1,18 @@ <template> <div> <div>{{ script.interpolate(value.title) }}</div> - <ui-radio v-for="x in value.values" v-model="v" :value="x" :key="x">{{ x }}</ui-radio> + <mk-radio v-for="x in value.values" v-model="v" :value="x" :key="x">{{ x }}</mk-radio> </div> </template> <script lang="ts"> import Vue from 'vue'; +import MkRadio from '../ui/radio.vue'; export default Vue.extend({ + components: { + MkRadio + }, props: { value: { required: true @@ -17,13 +21,11 @@ export default Vue.extend({ required: true } }, - data() { return { v: this.value.default, }; }, - watch: { v() { this.script.aiScript.updatePageVar(this.value.name, this.v); @@ -32,6 +34,3 @@ export default Vue.extend({ } }); </script> - -<style lang="stylus" scoped> -</style> diff --git a/src/client/app/common/views/components/page/page.section.vue b/src/client/components/page/page.section.vue index 03c009d9c3..b83c773f71 100644 --- a/src/client/app/common/views/components/page/page.section.vue +++ b/src/client/components/page/page.section.vue @@ -26,30 +26,33 @@ export default Vue.extend({ required: true } }, - beforeCreate() { - this.$options.components.XBlock = require('./page.block.vue').default + this.$options.components.XBlock = require('./page.block.vue').default; }, }); </script> -<style lang="stylus" scoped> -.sdgxphyu - margin 1.5em 0 +<style lang="scss" scoped> +.sdgxphyu { + margin: 1.5em 0; - > h2 - font-size 1.35em - margin 0 0 0.5em 0 + > h2 { + font-size: 1.35em; + margin: 0 0 0.5em 0; + } - > h3 - font-size 1em - margin 0 0 0.5em 0 + > h3 { + font-size: 1em; + margin: 0 0 0.5em 0; + } - > h4 - font-size 1em - margin 0 0 0.5em 0 + > h4 { + font-size: 1em; + margin: 0 0 0.5em 0; + } - > .children + > .children { //padding 16px - + } +} </style> diff --git a/src/client/app/common/views/components/page/page.switch.vue b/src/client/components/page/page.switch.vue index 53695f1b36..416c36e9ad 100644 --- a/src/client/app/common/views/components/page/page.switch.vue +++ b/src/client/components/page/page.switch.vue @@ -1,13 +1,17 @@ <template> <div class="hkcxmtwj"> - <ui-switch v-model="v">{{ script.interpolate(value.text) }}</ui-switch> + <mk-switch v-model="v">{{ script.interpolate(value.text) }}</mk-switch> </div> </template> <script lang="ts"> import Vue from 'vue'; +import MkSwitch from '../ui/switch.vue'; export default Vue.extend({ + components: { + MkSwitch + }, props: { value: { required: true @@ -16,13 +20,11 @@ export default Vue.extend({ required: true } }, - data() { return { v: this.value.default, }; }, - watch: { v() { this.script.aiScript.updatePageVar(this.value.name, this.v); @@ -32,12 +34,13 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.hkcxmtwj - display inline-block - margin 16px auto - - & + .hkcxmtwj - margin-left 16px +<style lang="scss" scoped> +.hkcxmtwj { + display: inline-block; + margin: 16px auto; + & + .hkcxmtwj { + margin-left: 16px; + } +} </style> diff --git a/src/client/app/common/views/components/page/page.text-input.vue b/src/client/components/page/page.text-input.vue index cf917dd5a8..fcc181d673 100644 --- a/src/client/app/common/views/components/page/page.text-input.vue +++ b/src/client/components/page/page.text-input.vue @@ -1,13 +1,17 @@ <template> <div> - <ui-input class="kudkigyw" v-model="v" type="text">{{ script.interpolate(value.text) }}</ui-input> + <mk-input class="kudkigyw" v-model="v" type="text">{{ script.interpolate(value.text) }}</mk-input> </div> </template> <script lang="ts"> import Vue from 'vue'; +import MkInput from '../ui/input.vue'; export default Vue.extend({ + components: { + MkInput + }, props: { value: { required: true @@ -16,13 +20,11 @@ export default Vue.extend({ required: true } }, - data() { return { v: this.value.default, }; }, - watch: { v() { this.script.aiScript.updatePageVar(this.value.name, this.v); @@ -32,10 +34,11 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.kudkigyw - display inline-block - min-width 300px - max-width 450px - margin 8px 0 +<style lang="scss" scoped> +.kudkigyw { + display: inline-block; + min-width: 300px; + max-width: 450px; + margin: 8px 0; +} </style> diff --git a/src/client/app/common/views/components/page/page.text.vue b/src/client/components/page/page.text.vue index 326fd39050..aeab31225e 100644 --- a/src/client/app/common/views/components/page/page.text.vue +++ b/src/client/components/page/page.text.vue @@ -1,15 +1,14 @@ <template> <div class="mrdgzndn"> <mfm :text="text" :is-note="false" :i="$store.state.i" :key="text"/> - <mk-url-preview v-for="url in urls" :url="url" :key="url" class="url"/> </div> </template> <script lang="ts"> import Vue from 'vue'; -import { parse } from '../../../../../../mfm/parse'; -import { unique } from '../../../../../../prelude/array'; +import { parse } from '../../../mfm/parse'; +import { unique } from '../../../prelude/array'; export default Vue.extend({ props: { @@ -20,13 +19,11 @@ export default Vue.extend({ required: true } }, - data() { return { text: this.script.interpolate(this.value.text), }; }, - computed: { urls(): string[] { if (this.text) { @@ -40,23 +37,29 @@ export default Vue.extend({ } } }, - - created() { - this.$watch('script.vars', () => { - this.text = this.script.interpolate(this.value.text); - }, { deep: true }); - } + watch: { + 'script.vars': { + handler() { + this.text = this.script.interpolate(this.value.text); + }, + deep: true + } + }, }); </script> -<style lang="stylus" scoped> -.mrdgzndn - &:not(:first-child) - margin-top 0.5em +<style lang="scss" scoped> +.mrdgzndn { + &:not(:first-child) { + margin-top: 0.5em; + } - &:not(:last-child) - margin-bottom 0.5em + &:not(:last-child) { + margin-bottom: 0.5em; + } - > .url - margin 0.5em 0 + > .url { + margin: 0.5em 0; + } +} </style> diff --git a/src/client/app/common/views/components/page/page.textarea-input.vue b/src/client/components/page/page.textarea-input.vue index eece59fefb..d1cf9813c4 100644 --- a/src/client/app/common/views/components/page/page.textarea-input.vue +++ b/src/client/components/page/page.textarea-input.vue @@ -1,13 +1,17 @@ <template> <div> - <ui-textarea class="" v-model="v">{{ script.interpolate(value.text) }}</ui-textarea> + <mk-textarea v-model="v">{{ script.interpolate(value.text) }}</mk-textarea> </div> </template> <script lang="ts"> import Vue from 'vue'; +import MkTextarea from '../ui/textarea.vue'; export default Vue.extend({ + components: { + MkTextarea + }, props: { value: { required: true @@ -16,13 +20,11 @@ export default Vue.extend({ required: true } }, - data() { return { v: this.value.default, }; }, - watch: { v() { this.script.aiScript.updatePageVar(this.value.name, this.v); @@ -31,6 +33,3 @@ export default Vue.extend({ } }); </script> - -<style lang="stylus" scoped> -</style> diff --git a/src/client/app/common/views/components/page/page.textarea.vue b/src/client/components/page/page.textarea.vue index 03c8542cb0..78b74dd64c 100644 --- a/src/client/app/common/views/components/page/page.textarea.vue +++ b/src/client/components/page/page.textarea.vue @@ -1,11 +1,15 @@ <template> -<ui-textarea class="" :value="text" readonly></ui-textarea> +<mk-textarea :value="text" readonly></mk-textarea> </template> <script lang="ts"> import Vue from 'vue'; +import MkTextarea from '../ui/textarea.vue'; export default Vue.extend({ + components: { + MkTextarea + }, props: { value: { required: true @@ -14,20 +18,18 @@ export default Vue.extend({ required: true } }, - data() { return { text: this.script.interpolate(this.value.text), }; }, - - created() { - this.$watch('script.vars', () => { - this.text = this.script.interpolate(this.value.text); - }, { deep: true }); + watch: { + 'script.vars': { + handler() { + this.text = this.script.interpolate(this.value.text); + }, + deep: true + } } }); </script> - -<style lang="stylus" scoped> -</style> diff --git a/src/client/app/common/views/components/page/page.vue b/src/client/components/page/page.vue index 1bfb93780f..bd78313475 100644 --- a/src/client/app/common/views/components/page/page.vue +++ b/src/client/components/page/page.vue @@ -1,5 +1,5 @@ <template> -<div class="iroscrza" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners, center: page.alignCenter, serif: page.font === 'serif' }"> +<div class="iroscrza" :class="{ center: page.alignCenter, serif: page.font === 'serif' }"> <header v-if="showTitle"> <div class="title">{{ page.title }}</div> </header> @@ -11,7 +11,7 @@ <footer v-if="showFooter"> <small>@{{ page.user.username }}</small> <template v-if="$store.getters.isSignedIn && $store.state.i.id === page.userId"> - <router-link :to="`/i/pages/edit/${page.id}`">{{ $t('edit-this-page') }}</router-link> + <router-link :to="`/my/pages/edit/${page.id}`">{{ $t('edit-this-page') }}</router-link> <a v-if="$store.state.i.pinnedPageId === page.id" @click="pin(false)">{{ $t('unpin-this-page') }}</a> <a v-else @click="pin(true)">{{ $t('pin-this-page') }}</a> </template> @@ -27,13 +27,13 @@ <script lang="ts"> import Vue from 'vue'; -import i18n from '../../../../i18n'; +import i18n from '../../i18n'; import { faHeart as faHeartS } from '@fortawesome/free-solid-svg-icons'; import { faHeart } from '@fortawesome/free-regular-svg-icons'; import XBlock from './page.block.vue'; -import { ASEvaluator } from '../../../../../../misc/aiscript/evaluator'; -import { collectPageVars } from '../../../scripts/collect-page-vars'; -import { url } from '../../../../config'; +import { ASEvaluator } from '../../scripts/aiscript/evaluator'; +import { collectPageVars } from '../../scripts/collect-page-vars'; +import { url } from '../../config'; class Script { public aiScript: ASEvaluator; @@ -66,7 +66,7 @@ class Script { } export default Vue.extend({ - i18n: i18n('pages'), + i18n, components: { XBlock @@ -146,77 +146,85 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.iroscrza - overflow hidden - background var(--face) - - &.serif - > div - font-family serif - - &.center - text-align center - - &.round - border-radius 6px - - &.shadow - box-shadow 0 3px 8px rgba(0, 0, 0, 0.2) +<style lang="scss" scoped> +.iroscrza { + &.serif { + > div { + font-family: serif; + } + } - > header - > .title - z-index 1 - margin 0 - padding 16px 32px - font-size 20px - font-weight bold - color var(--text) - box-shadow 0 var(--lineWidth) rgba(#000, 0.07) + &.center { + text-align: center; + } - @media (max-width 600px) - padding 16px 32px - font-size 20px + > header { + > .title { + z-index: 1; + margin: 0; + padding: 16px 32px; + font-size: 20px; + font-weight: bold; + color: var(--text); + box-shadow: 0 var(--lineWidth) rgba(#000, 0.07); - @media (max-width 400px) - padding 10px 20px - font-size 16px + @media (max-width: 600px) { + padding: 16px 32px; + font-size: 20px; + } - > div - color var(--text) - padding 24px 32px - font-size 16px + @media (max-width: 400px) { + padding: 10px 20px; + font-size: 16px; + } + } + } - @media (max-width 600px) - padding 24px 32px - font-size 16px + > div { + color: var(--text); + padding: 24px 32px; + font-size: 16px; - @media (max-width 400px) - padding 20px 20px - font-size 15px + @media (max-width: 600px) { + padding: 24px 32px; + font-size: 16px; + } - > footer - color var(--text) - padding 0 32px 28px 32px + @media (max-width: 400px) { + padding: 20px 20px; + font-size: 15px; + } + } - @media (max-width 600px) - padding 0 32px 28px 32px + > footer { + color: var(--text); + padding: 0 32px 28px 32px; - @media (max-width 400px) - padding 0 20px 20px 20px - font-size 14px + @media (max-width: 600px) { + padding: 0 32px 28px 32px; + } - > small - display block - opacity 0.5 + @media (max-width: 400px) { + padding: 0 20px 20px 20px; + font-size: 14px; + } - > a - font-size 90% + > small { + display: block; + opacity: 0.5; + } - > a + a - margin-left 8px + > a { + font-size: 90%; + } - > .like - margin-top 16px + > a + a { + margin-left: 8px; + } + > .like { + margin-top: 16px; + } + } +} </style> diff --git a/src/client/components/poll-editor.vue b/src/client/components/poll-editor.vue new file mode 100644 index 0000000000..b5b8c2c02d --- /dev/null +++ b/src/client/components/poll-editor.vue @@ -0,0 +1,218 @@ +<template> +<div class="zmdxowus"> + <p class="caution" v-if="choices.length < 2"> + <fa :icon="faExclamationTriangle"/>{{ $t('_poll.noOnlyOneChoice') }} + </p> + <ul ref="choices"> + <li v-for="(choice, i) in choices" :key="i"> + <mk-input class="input" :value="choice" @input="onInput(i, $event)"> + <span>{{ $t('_poll.choiceN', { n: i + 1 }) }}</span> + </mk-input> + <button @click="remove(i)" class="_button"> + <fa :icon="faTimes"/> + </button> + </li> + </ul> + <mk-button class="add" v-if="choices.length < 10" @click="add">{{ $t('add') }}</mk-button> + <mk-button class="add" v-else disabled>{{ $t('_poll.noMore') }}</mk-button> + <section> + <mk-switch v-model="multiple">{{ $t('_poll.canMultipleVote') }}</mk-switch> + <div> + <mk-select v-model="expiration"> + <template #label>{{ $t('_poll.expiration') }}</template> + <option value="infinite">{{ $t('_poll.infinite') }}</option> + <option value="at">{{ $t('_poll.at') }}</option> + <option value="after">{{ $t('_poll.after') }}</option> + </mk-select> + <section v-if="expiration === 'at'"> + <mk-input v-model="atDate" type="date" class="input"> + <span>{{ $t('_poll.deadlineDate') }}</span> + </mk-input> + <mk-input v-model="atTime" type="time" class="input"> + <span>{{ $t('_poll.deadlineTime') }}</span> + </mk-input> + </section> + <section v-if="expiration === 'after'"> + <mk-input v-model="after" type="number" class="input"> + <span>{{ $t('_poll.duration') }}</span> + </mk-input> + <mk-select v-model="unit"> + <option value="second">{{ $t('_time.second') }}</option> + <option value="minute">{{ $t('_time.minute') }}</option> + <option value="hour">{{ $t('_time.hour') }}</option> + <option value="day">{{ $t('_time.day') }}</option> + </mk-select> + </section> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faExclamationTriangle, faTimes } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import { erase } from '../../prelude/array'; +import { addTimespan } from '../../prelude/time'; +import { formatDateTimeString } from '../../misc/format-time-string'; +import MkInput from './ui/input.vue'; +import MkSelect from './ui/select.vue'; +import MkSwitch from './ui/switch.vue'; +import MkButton from './ui/button.vue'; + +export default Vue.extend({ + i18n, + components: { + MkInput, + MkSelect, + MkSwitch, + MkButton, + }, + data() { + return { + choices: ['', ''], + multiple: false, + expiration: 'infinite', + atDate: formatDateTimeString(addTimespan(new Date(), 1, 'days'), 'yyyy-MM-dd'), + atTime: '00:00', + after: 0, + unit: 'second', + faExclamationTriangle, faTimes + }; + }, + watch: { + choices() { + this.$emit('updated'); + } + }, + methods: { + onInput(i, e) { + Vue.set(this.choices, i, e); + }, + + add() { + this.choices.push(''); + this.$nextTick(() => { + (this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus(); + }); + }, + + remove(i) { + this.choices = this.choices.filter((_, _i) => _i != i); + }, + + get() { + const at = () => { + return new Date(`${this.atDate} ${this.atTime}`).getTime(); + }; + + const after = () => { + let base = parseInt(this.after); + switch (this.unit) { + case 'day': base *= 24; + case 'hour': base *= 60; + case 'minute': base *= 60; + case 'second': return base *= 1000; + default: return null; + } + }; + + return { + choices: erase('', this.choices), + multiple: this.multiple, + ...( + this.expiration === 'at' ? { expiresAt: at() } : + this.expiration === 'after' ? { expiredAfter: after() } : {}) + }; + }, + + set(data) { + if (data.choices.length == 0) return; + this.choices = data.choices; + if (data.choices.length == 1) this.choices = this.choices.concat(''); + this.multiple = data.multiple; + if (data.expiresAt) { + this.expiration = 'at'; + this.atDate = this.atTime = data.expiresAt; + } else if (typeof data.expiredAfter === 'number') { + this.expiration = 'after'; + this.after = data.expiredAfter; + } else { + this.expiration = 'infinite'; + } + } + } +}); +</script> + +<style lang="scss" scoped> +.zmdxowus { + padding: 8px; + + > .caution { + margin: 0 0 8px 0; + font-size: 0.8em; + color: #f00; + + > [data-icon] { + margin-right: 4px; + } + } + + > ul { + display: block; + margin: 0; + padding: 0; + list-style: none; + + > li { + display: flex; + margin: 8px 0; + padding: 0; + width: 100%; + + > .input { + flex: 1; + margin-top: 16px; + margin-bottom: 0; + } + + > button { + width: 32px; + padding: 4px 0; + } + } + } + + > .add { + margin: 8px 0 0 0; + z-index: 1; + } + + > section { + margin: 16px 0 -16px 0; + + > div { + margin: 0 8px; + + &:last-child { + flex: 1 0 auto; + + > section { + align-items: center; + display: flex; + margin: -32px 0 0; + + > &:first-child { + margin-right: 16px; + } + + > .input { + flex: 1 0 auto; + } + } + } + } + } +} +</style> diff --git a/src/client/components/poll.vue b/src/client/components/poll.vue new file mode 100644 index 0000000000..15be1b282d --- /dev/null +++ b/src/client/components/poll.vue @@ -0,0 +1,174 @@ +<template> +<div class="mk-poll" :data-done="closed || isVoted"> + <ul> + <li v-for="(choice, i) in poll.choices" :key="i" @click="vote(i)" :class="{ voted: choice.voted }"> + <div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> + <span> + <template v-if="choice.isVoted"><fa :icon="faCheck"/></template> + <mfm :text="choice.text" :plain="true" :custom-emojis="note.emojis"/> + <span class="votes" v-if="showResult">({{ $t('_poll.votesCount', { n: choice.votes }) }})</span> + </span> + </li> + </ul> + <p> + <span>{{ $t('_poll.totalVotes', { n: total }) }}</span> + <span> · </span> + <a v-if="!closed && !isVoted" @click="toggleShowResult">{{ showResult ? $t('_poll.vote') : $t('_poll.showResult') }}</a> + <span v-if="isVoted">{{ $t('_poll.voted') }}</span> + <span v-else-if="closed">{{ $t('_poll.closed') }}</span> + <span v-if="remaining > 0"> · {{ timer }}</span> + </p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faCheck } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import { sum } from '../../prelude/array'; + +export default Vue.extend({ + i18n, + props: { + note: { + type: Object, + required: true + } + }, + data() { + return { + remaining: -1, + showResult: false, + faCheck + }; + }, + computed: { + poll(): any { + return this.note.poll; + }, + total(): number { + return sum(this.poll.choices.map(x => x.votes)); + }, + closed(): boolean { + return !this.remaining; + }, + timer(): string { + return this.$t( + this.remaining > 86400 ? '_poll.remainingDays' : + this.remaining > 3600 ? '_poll.remainingHours' : + this.remaining > 60 ? '_poll.remainingMinutes' : '_poll.remainingSeconds', { + s: Math.floor(this.remaining % 60), + m: Math.floor(this.remaining / 60) % 60, + h: Math.floor(this.remaining / 3600) % 24, + d: Math.floor(this.remaining / 86400) + }); + }, + isVoted(): boolean { + return !this.poll.multiple && this.poll.choices.some(c => c.isVoted); + } + }, + created() { + this.showResult = this.isVoted; + + if (this.note.poll.expiresAt) { + const update = () => { + if (this.remaining = Math.floor(Math.max(new Date(this.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000)) + requestAnimationFrame(update); + else + this.showResult = true; + }; + + update(); + } + }, + methods: { + toggleShowResult() { + this.showResult = !this.showResult; + }, + vote(id) { + if (this.closed || !this.poll.multiple && this.poll.choices.some(c => c.isVoted)) return; + this.$root.api('notes/polls/vote', { + noteId: this.note.id, + choice: id + }).then(() => { + if (!this.showResult) this.showResult = !this.poll.multiple; + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-poll { + > ul { + display: block; + margin: 0; + padding: 0; + list-style: none; + + > li { + display: block; + position: relative; + margin: 4px 0; + padding: 4px 8px; + width: 100%; + color: var(--pollChoiceText); + border: solid 1px var(--pollChoiceBorder); + border-radius: 4px; + overflow: hidden; + cursor: pointer; + + &:hover { + background: rgba(#000, 0.05); + } + + &:active { + background: rgba(#000, 0.1); + } + + > .backdrop { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--accent); + transition: width 1s ease; + } + + > span { + position: relative; + + > [data-icon] { + margin-right: 4px; + } + + > .votes { + margin-left: 4px; + } + } + } + } + + > p { + color: var(--fg); + + a { + color: inherit; + } + } + + &[data-done] { + > ul > li { + cursor: default; + + &:hover { + background: transparent; + } + + &:active { + background: transparent; + } + } + } +} +</style> diff --git a/src/client/components/popup.vue b/src/client/components/popup.vue new file mode 100644 index 0000000000..d5b1f9423b --- /dev/null +++ b/src/client/components/popup.vue @@ -0,0 +1,147 @@ +<template> +<div class="mk-popup"> + <transition name="bg-fade" appear> + <div class="bg" ref="bg" @click="close()" v-if="show"></div> + </transition> + <transition name="popup" appear @after-leave="() => { $emit('closed'); destroyDom(); }"> + <div class="content" :class="{ fixed }" ref="content" v-if="show" :style="{ width: width ? width + 'px' : 'auto' }"><slot></slot></div> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + source: { + required: true + }, + noCenter: { + type: Boolean, + required: false + }, + fixed: { + type: Boolean, + required: false + }, + width: { + type: Number, + required: false + } + }, + data() { + return { + show: true, + }; + }, + mounted() { + this.$nextTick(() => { + const popover = this.$refs.content as any; + + const rect = this.source.getBoundingClientRect(); + const width = popover.offsetWidth; + const height = popover.offsetHeight; + + let left; + let top; + + if (this.$root.isMobile && !this.noCenter) { + const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.source.offsetWidth / 2); + const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.source.offsetHeight / 2); + left = (x - (width / 2)); + top = (y - (height / 2)); + popover.style.transformOrigin = 'center'; + } else { + const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.source.offsetWidth / 2); + const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.source.offsetHeight; + left = (x - (width / 2)); + top = y; + } + + if (this.fixed) { + if (left + width > window.innerWidth) { + left = window.innerWidth - width; + popover.style.transformOrigin = 'center'; + } + + if (top + height > window.innerHeight) { + top = window.innerHeight - height; + popover.style.transformOrigin = 'center'; + } + } else { + if (left + width - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - width + window.pageXOffset; + popover.style.transformOrigin = 'center'; + } + + if (top + height - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - height + window.pageYOffset; + popover.style.transformOrigin = 'center'; + } + } + + if (top < 0) { + top = 0; + } + + if (left < 0) { + left = 0; + } + + popover.style.left = left + 'px'; + popover.style.top = top + 'px'; + }); + }, + methods: { + close() { + this.show = false; + (this.$refs.bg as any).style.pointerEvents = 'none'; + (this.$refs.content as any).style.pointerEvents = 'none'; + } + } +}); +</script> + +<style lang="scss" scoped> +.popup-enter-active, .popup-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.popup-enter, .popup-leave-to { + opacity: 0; + transform: scale(0.9); +} + +.bg-fade-enter-active, .bg-fade-leave-active { + transition: opacity 0.3s !important; +} +.bg-fade-enter, .bg-fade-leave-to { + opacity: 0; +} + +.mk-popup { + > .bg { + position: fixed; + top: 0; + left: 0; + z-index: 10000; + width: 100%; + height: 100%; + background: var(--modalBg) + } + + > .content { + position: absolute; + z-index: 10001; + background: var(--panel); + border-radius: 4px; + box-shadow: 0 3px 12px rgba(27, 31, 35, 0.15); + overflow: hidden; + transform-origin: center top; + + &.fixed { + position: fixed; + } + } +} +</style> diff --git a/src/client/components/post-form-attaches.vue b/src/client/components/post-form-attaches.vue new file mode 100644 index 0000000000..50ba9bfdcf --- /dev/null +++ b/src/client/components/post-form-attaches.vue @@ -0,0 +1,158 @@ +<template> +<div class="skeikyzd" v-show="files.length != 0"> + <x-draggable class="files" :list="files" animation="150"> + <div v-for="file in files" :key="file.id" @click="showFileMenu(file, $event)" @contextmenu.prevent="showFileMenu(file, $event)"> + <x-file-thumbnail :data-id="file.id" class="thumbnail" :file="file" fit="cover"/> + <div class="sensitive" v-if="file.isSensitive"> + <fa class="icon" :icon="faExclamationTriangle"/> + </div> + </div> + </x-draggable> + <p class="remain">{{ 4 - files.length }}/4</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import * as XDraggable from 'vuedraggable'; +import { faTimesCircle, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; +import { faExclamationTriangle, faICursor } from '@fortawesome/free-solid-svg-icons'; +import XFileThumbnail from './drive-file-thumbnail.vue' + +export default Vue.extend({ + i18n, + + components: { + XDraggable, + XFileThumbnail + }, + + props: { + files: { + type: Array, + required: true + }, + detachMediaFn: { + type: Function, + required: false + } + }, + + data() { + return { + faExclamationTriangle + }; + }, + + methods: { + detachMedia(id) { + if (this.detachMediaFn) { + this.detachMediaFn(id); + } else if (this.$parent.detachMedia) { + this.$parent.detachMedia(id); + } + }, + toggleSensitive(file) { + this.$root.api('drive/files/update', { + fileId: file.id, + isSensitive: !file.isSensitive + }).then(() => { + file.isSensitive = !file.isSensitive; + this.$parent.updateMedia(file); + }); + }, + async rename(file) { + const { canceled, result } = await this.$root.dialog({ + title: this.$t('enterFileName'), + input: { + default: file.name + }, + allowEmpty: false + }); + if (canceled) return; + this.$root.api('drive/files/update', { + fileId: file.id, + name: result + }).then(() => { + file.name = result; + this.$parent.updateMedia(file); + }); + }, + showFileMenu(file, ev: MouseEvent) { + this.$root.menu({ + items: [{ + text: this.$t('renameFile'), + icon: faICursor, + action: () => { this.rename(file) } + }, { + text: file.isSensitive ? this.$t('unmarkAsSensitive') : this.$t('markAsSensitive'), + icon: file.isSensitive ? faEyeSlash : faEye, + action: () => { this.toggleSensitive(file) } + }, { + text: this.$t('attachCancel'), + icon: faTimesCircle, + action: () => { this.detachMedia(file.id) } + }], + source: ev.currentTarget || ev.target + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.skeikyzd { + padding: 4px; + position: relative; + + > .files { + display: flex; + flex-wrap: wrap; + + > div { + position: relative; + width: 64px; + height: 64px; + margin: 4px; + cursor: move; + + &:hover > .remove { + display: block; + } + + > .thumbnail { + width: 100%; + height: 100%; + z-index: 1; + color: var(--fg); + } + + > .sensitive { + display: flex; + position: absolute; + width: 64px; + height: 64px; + top: 0; + left: 0; + z-index: 2; + background: rgba(17, 17, 17, .7); + color: #fff; + + > .icon { + margin: auto; + } + } + } + } + + > .remain { + display: block; + position: absolute; + top: 8px; + right: 8px; + margin: 0; + padding: 0; + } +} +</style> diff --git a/src/client/components/post-form-dialog.vue b/src/client/components/post-form-dialog.vue new file mode 100644 index 0000000000..fe70b88218 --- /dev/null +++ b/src/client/components/post-form-dialog.vue @@ -0,0 +1,157 @@ +<template> +<div class="ulveipglmagnxfgvitaxyszerjwiqmwl"> + <transition name="form-fade" appear> + <div class="bg" ref="bg" v-if="show" @click="close()"></div> + </transition> + <div class="main" ref="main" @click.self="close()" @keydown="onKeydown"> + <transition name="form" appear + @after-leave="destroyDom" + > + <x-post-form ref="form" + v-if="show" + :reply="reply" + :renote="renote" + :mention="mention" + :specified="specified" + :initial-text="initialText" + :initial-note="initialNote" + :instant="instant" + @posted="onPosted" + @cancel="onCanceled"/> + </transition> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XPostForm from './post-form.vue'; + +export default Vue.extend({ + components: { + XPostForm + }, + + props: { + reply: { + type: Object, + required: false + }, + renote: { + type: Object, + required: false + }, + mention: { + type: Object, + required: false + }, + specified: { + type: Object, + required: false + }, + initialText: { + type: String, + required: false + }, + initialNote: { + type: Object, + required: false + }, + instant: { + type: Boolean, + required: false, + default: false + } + }, + + data() { + return { + show: true + }; + }, + + methods: { + focus() { + this.$refs.form.focus(); + }, + + close() { + this.show = false; + (this.$refs.bg as any).style.pointerEvents = 'none'; + (this.$refs.main as any).style.pointerEvents = 'none'; + }, + + onPosted() { + this.$emit('posted'); + this.close(); + }, + + onCanceled() { + this.$emit('cancel'); + this.close(); + }, + + onKeydown(e) { + if (e.which === 27) { // Esc + e.preventDefault(); + e.stopPropagation(); + this.close(); + } + }, + } +}); +</script> + +<style lang="scss" scoped> +.form-enter-active, .form-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.form-enter, .form-leave-to { + opacity: 0; + transform: scale(0.9); +} + +.form-fade-enter-active, .form-fade-leave-active { + transition: opacity 0.3s !important; +} +.form-fade-enter, .form-fade-leave-to { + opacity: 0; +} + +.ulveipglmagnxfgvitaxyszerjwiqmwl { + > .bg { + display: block; + position: fixed; + z-index: 10000; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(#000, 0.7); + } + + > .main { + display: block; + position: fixed; + z-index: 10000; + top: 32px; + left: 0; + right: 0; + height: calc(100% - 64px); + width: 500px; + max-width: calc(100% - 16px); + overflow: auto; + margin: 0 auto 0 auto; + + @media (max-width: 550px) { + top: 16px; + height: calc(100% - 32px); + } + + @media (max-width: 520px) { + top: 8px; + height: calc(100% - 16px); + } + } +} +</style> diff --git a/src/client/app/common/scripts/post-form.ts b/src/client/components/post-form.vue index 496782fd30..762b82036b 100644 --- a/src/client/app/common/scripts/post-form.ts +++ b/src/client/components/post-form.vue @@ -1,21 +1,82 @@ +<template> +<div class="gafaadew" + @dragover.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <header> + <button class="cancel _button" @click="cancel"><fa :icon="faTimes"/></button> + <div> + <span class="text-count" :class="{ over: trimmedLength(text) > 500 }">{{ 500 - trimmedLength(text) }}</span> + <button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}</button> + </div> + </header> + <div class="form"> + <x-note-preview class="preview" v-if="reply" :note="reply"/> + <x-note-preview class="preview" v-if="renote" :note="renote"/> + <div class="with-quote" v-if="quoteId"><fa icon="quote-left"/> {{ $t('@.post-form.quote-attached') }}<button @click="quoteId = null"><fa icon="times"/></button></div> + <div v-if="visibility === 'specified'" class="to-specified"> + <span style="margin-right: 8px;">{{ $t('recipient') }}</span> + <div class="visibleUsers"> + <span v-for="u in visibleUsers"> + <mk-acct :user="u"/> + <button class="_button" @click="removeVisibleUser(u)"><fa :icon="faTimes"/></button> + </span> + <button @click="addVisibleUser" class="_buttonPrimary"><fa :icon="faPlus" fixed-width/></button> + </div> + </div> + <input v-show="useCw" ref="cw" v-model="cw" :placeholder="$t('annotation')" v-autocomplete="{ model: 'cw' }"> + <textarea v-model="text" ref="text" :disabled="posting" :placeholder="placeholder" v-autocomplete="{ model: 'text' }" @keydown="onKeydown" @paste="onPaste"></textarea> + <x-post-form-attaches class="attaches" :files="files"/> + <x-poll-editor v-if="poll" ref="poll" @destroyed="poll = false" @updated="onPollUpdate()"/> + <x-uploader ref="uploader" @uploaded="attachMedia" @change="onChangeUploadings"/> + <footer> + <button class="_button" @click="chooseFileFrom"><fa :icon="faPhotoVideo"/></button> + <button class="_button" @click="poll = !poll"><fa :icon="faChartPie"/></button> + <button class="_button" @click="useCw = !useCw"><fa :icon="faEyeSlash"/></button> + <button class="_button" @click="insertMention"><fa :icon="faAt"/></button> + <button class="_button" @click="insertEmoji"><fa :icon="faLaughSquint"/></button> + <button class="_button" @click="setVisibility" ref="visibilityButton"> + <span v-if="visibility === 'public'"><fa :icon="faGlobe"/></span> + <span v-if="visibility === 'home'"><fa :icon="faHome"/></span> + <span v-if="visibility === 'followers'"><fa :icon="faUnlock"/></span> + <span v-if="visibility === 'specified'"><fa :icon="faEnvelope"/></span> + </button> + </footer> + <input ref="file" class="file _button" type="file" multiple="multiple" @change="onChangeFile"/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faTimes, faUpload, faChartPie, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faCloud, faLink, faAt } from '@fortawesome/free-solid-svg-icons'; +import { faEyeSlash, faLaughSquint } from '@fortawesome/free-regular-svg-icons'; import insertTextAtCursor from 'insert-text-at-cursor'; import { length } from 'stringz'; import { toASCII } from 'punycode'; -import MkVisibilityChooser from '../views/components/visibility-chooser.vue'; -import getFace from './get-face'; -import { parse } from '../../../../mfm/parse'; -import { host, url } from '../../config'; -import i18n from '../../i18n'; -import { erase, unique } from '../../../../prelude/array'; -import extractMentions from '../../../../misc/extract-mentions'; -import { formatTimeString } from '../../../../misc/format-time-string'; +import i18n from '../i18n'; +import MkVisibilityChooser from './visibility-chooser.vue'; +import MkUserSelect from './user-select.vue'; +import XNotePreview from './note-preview.vue'; +import XEmojiPicker from './emoji-picker.vue'; +import { parse } from '../../mfm/parse'; +import { host, url } from '../config'; +import { erase, unique } from '../../prelude/array'; +import extractMentions from '../../misc/extract-mentions'; +import getAcct from '../../misc/acct/render'; +import { formatTimeString } from '../../misc/format-time-string'; +import { selectDriveFile } from '../scripts/select-drive-file'; -export default (opts) => ({ - i18n: i18n(), +export default Vue.extend({ + i18n, components: { - XPostFormAttaches: () => import('../views/components/post-form-attaches.vue').then(m => m.default), - XPollEditor: () => import('../views/components/poll-editor.vue').then(m => m.default) + XNotePreview, + XUploader: () => import('./uploader.vue').then(m => m.default), + XPostFormAttaches: () => import('./post-form-attaches.vue').then(m => m.default), + XPollEditor: () => import('./poll-editor.vue').then(m => m.default) }, props: { @@ -31,6 +92,10 @@ export default (opts) => ({ type: Object, required: false }, + specified: { + type: Object, + required: false + }, initialText: { type: String, required: false @@ -58,15 +123,13 @@ export default (opts) => ({ pollExpiration: [], useCw: false, cw: null, - geo: null, visibility: 'public', visibleUsers: [], - localOnly: false, autocomplete: null, draghover: false, quoteId: null, recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), - maxNoteTextLength: 1000 + faTimes, faUpload, faChartPie, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faCloud, faLink, faAt }; }, @@ -81,44 +144,38 @@ export default (opts) => ({ placeholder(): string { const xs = [ - this.$t('@.note-placeholders.a'), - this.$t('@.note-placeholders.b'), - this.$t('@.note-placeholders.c'), - this.$t('@.note-placeholders.d'), - this.$t('@.note-placeholders.e'), - this.$t('@.note-placeholders.f') + this.$t('_postForm._placeholders.a'), + this.$t('_postForm._placeholders.b'), + this.$t('_postForm._placeholders.c'), + this.$t('_postForm._placeholders.d'), + this.$t('_postForm._placeholders.e'), + this.$t('_postForm._placeholders.f') ]; const x = xs[Math.floor(Math.random() * xs.length)]; - + return this.renote - ? opts.mobile ? this.$t('@.post-form.option-quote-placeholder') : this.$t('@.post-form.quote-placeholder') + ? this.$t('_postForm.quotePlaceholder') : this.reply - ? this.$t('@.post-form.reply-placeholder') + ? this.$t('_postForm.replyPlaceholder') : x; }, submitText(): string { return this.renote - ? this.$t('@.post-form.renote') + ? this.$t('renote') : this.reply - ? this.$t('@.post-form.reply') - : this.$t('@.post-form.submit'); + ? this.$t('reply') + : this.$t('_postForm.post'); }, canPost(): boolean { return !this.posting && (1 <= this.text.length || 1 <= this.files.length || this.poll || this.renote) && - (length(this.text.trim()) <= this.maxNoteTextLength) && + (length(this.text.trim()) <= 500) && (!this.poll || this.pollChoices.length >= 2); } }, - created() { - this.$root.getMeta().then(meta => { - this.maxNoteTextLength = meta.maxNoteTextLength; - }); - }, - mounted() { if (this.initialText) { this.text = this.initialText; @@ -153,10 +210,6 @@ export default (opts) => ({ // デフォルト公開範囲 this.applyVisibility(this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility); - if (this.reply && this.reply.localOnly) { - this.localOnly = true; - } - // 公開以外へのリプライ時は元の公開範囲を引き継ぐ if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) { this.visibility = this.reply.visibility; @@ -175,6 +228,11 @@ export default (opts) => ({ } } + if (this.specified) { + this.visibility = 'specified'; + this.visibleUsers.push(this.specified); + } + // keep cw when reply if (this.$store.state.settings.keepCw && this.reply && this.reply.cw) { this.useCw = true; @@ -220,10 +278,7 @@ export default (opts) => ({ }); }); } - // hack 位置情報投稿が動くようになったら適用する - this.geo = null; this.visibility = init.visibility; - this.localOnly = init.localOnly; this.quoteId = init.renote ? init.renote.id : null; } @@ -250,26 +305,50 @@ export default (opts) => ({ (this.$refs.text as any).focus(); }, - chooseFile() { + chooseFileFrom(ev) { + this.$root.menu({ + items: [{ + type: 'label', + text: this.$t('attachFile'), + }, { + text: this.$t('upload'), + icon: faUpload, + action: () => { this.chooseFileFromPc() } + }, { + text: this.$t('fromDrive'), + icon: faCloud, + action: () => { this.chooseFileFromDrive() } + }, { + text: this.$t('fromUrl'), + icon: faLink, + action: () => { this.chooseFileFromUrl() } + }], + source: ev.currentTarget || ev.target + }); + }, + + chooseFileFromPc() { (this.$refs.file as any).click(); }, chooseFileFromDrive() { - this.$chooseDriveFile({ - multiple: true - }).then(files => { - for (const x of files) this.attachMedia(x); + selectDriveFile(this.$root, true).then(files => { + for (const file of files) { + this.attachMedia(file); + } }); }, attachMedia(driveFile) { this.files.push(driveFile); - this.$emit('change-attached-files', this.files); }, detachMedia(id) { this.files = this.files.filter(x => x.id != id); - this.$emit('change-attached-files', this.files); + }, + + updateMedia(file) { + Vue.set(this.files, this.files.findIndex(x => x.id === file.id), file); }, onChangeFile() { @@ -292,64 +371,23 @@ export default (opts) => ({ this.saveDraft(); }, - setGeo() { - if (navigator.geolocation == null) { - this.$root.dialog({ - type: 'warning', - text: this.$t('@.post-form.geolocation-alert') - }); - return; - } - - navigator.geolocation.getCurrentPosition(pos => { - this.geo = pos.coords; - this.$emit('geo-attached', this.geo); - }, err => { - this.$root.dialog({ - type: 'error', - title: this.$t('@.post-form.error'), - text: err.message - }); - }, { - enableHighAccuracy: true - }); - }, - - removeGeo() { - this.geo = null; - this.$emit('geo-dettached'); - }, - setVisibility() { const w = this.$root.new(MkVisibilityChooser, { source: this.$refs.visibilityButton, - currentVisibility: this.localOnly ? `local-${this.visibility}` : this.visibility + currentVisibility: this.visibility }); w.$once('chosen', v => { this.applyVisibility(v); }); - this.$once('hook:beforeDestroy', () => { - w.close(); - }); }, applyVisibility(v: string) { - const m = v.match(/^local-(.+)/); - if (m) { - this.localOnly = true; - this.visibility = m[1]; - } else { - this.localOnly = false; - this.visibility = v; - } + this.visibility = v; }, addVisibleUser() { - this.$root.dialog({ - title: this.$t('@.post-form.enter-username'), - user: true - }).then(({ canceled, result: user }) => { - if (canceled) return; + const vm = this.$root.new(MkUserSelect, {}); + vm.$once('selected', user => { this.visibleUsers.push(user); }); }, @@ -377,16 +415,7 @@ export default (opts) => ({ const lio = file.name.lastIndexOf('.'); const ext = lio >= 0 ? file.name.slice(lio) : ''; const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.settings.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; - const name = this.$store.state.settings.pasteDialog - ? await this.$root.dialog({ - title: this.$t('@.post-form.enter-file-name'), - input: { - default: formatted - }, - allowEmpty: false - }).then(({ canceled, result }) => canceled ? false : result) - : formatted; - if (name) this.upload(file, name); + this.upload(file, formatted); } } @@ -411,6 +440,7 @@ export default (opts) => ({ }, onDragover(e) { + if (!e.dataTransfer.items[0]) return; const isFile = e.dataTransfer.items[0].kind == 'file'; const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; if (isFile || isDriveFile) { @@ -449,22 +479,6 @@ export default (opts) => ({ //#endregion }, - async emoji() { - const Picker = await import('../../desktop/views/components/emoji-picker-dialog.vue').then(m => m.default); - const button = this.$refs.emoji; - const rect = button.getBoundingClientRect(); - const vm = this.$root.new(Picker, { - x: button.offsetWidth + rect.left + window.pageXOffset, - y: rect.top + window.pageYOffset - }); - vm.$once('chosen', emoji => { - insertTextAtCursor(this.$refs.text, emoji); - }); - this.$once('hook:beforeDestroy', () => { - vm.close(); - }); - }, - saveDraft() { if (this.instant) return; @@ -490,13 +504,8 @@ export default (opts) => ({ localStorage.setItem('drafts', JSON.stringify(data)); }, - kao() { - this.text += getFace(); - }, - post() { this.posting = true; - const viaMobile = opts.mobile && !this.$store.state.settings.disableViaMobile; this.$root.api('notes/create', { text: this.text == '' ? undefined : this.text, fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, @@ -506,23 +515,12 @@ export default (opts) => ({ cw: this.useCw ? this.cw || '' : undefined, visibility: this.visibility, visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined, - localOnly: this.localOnly, - geo: this.geo ? { - coordinates: [this.geo.longitude, this.geo.latitude], - altitude: this.geo.altitude, - accuracy: this.geo.accuracy, - altitudeAccuracy: this.geo.altitudeAccuracy, - heading: isNaN(this.geo.heading) ? null : this.geo.heading, - speed: this.geo.speed, - } : null, - viaMobile: viaMobile + viaMobile: this.$root.isMobile }).then(data => { this.clear(); this.deleteDraft(); this.$emit('posted'); - if (opts.onSuccess) opts.onSuccess(this); }).catch(err => { - if (opts.onSuccess) opts.onFailure(this); }).then(() => { this.posting = false; }); @@ -533,5 +531,217 @@ export default (opts) => ({ localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history)))); } }, + + cancel() { + this.$emit('cancel'); + }, + + insertMention() { + const vm = this.$root.new(MkUserSelect, {}); + vm.$once('selected', user => { + insertTextAtCursor(this.$refs.text, getAcct(user) + ' '); + }); + }, + + insertEmoji(ev) { + const vm = this.$root.new(XEmojiPicker, { + source: ev.currentTarget || ev.target + }).$once('chosen', emoji => { + insertTextAtCursor(this.$refs.text, emoji); + vm.close(); + }); + } } }); +</script> + +<style lang="scss" scoped> +.gafaadew { + background: var(--panel); + border-radius: var(--radius); + box-shadow: 0 0 2px rgba(#000, 0.1); + + > header { + z-index: 1000; + height: 66px; + + @media (max-width: 500px) { + height: 50px; + } + + > .cancel { + padding: 0; + font-size: 20px; + width: 64px; + line-height: 66px; + + @media (max-width: 500px) { + width: 50px; + line-height: 50px; + } + } + + > div { + position: absolute; + top: 0; + right: 0; + + > .text-count { + line-height: 66px; + + @media (max-width: 500px) { + line-height: 50px; + } + } + + > .submit { + margin: 16px; + padding: 0 16px; + line-height: 34px; + vertical-align: bottom; + border-radius: 4px; + + @media (max-width: 500px) { + margin: 8px; + } + + &:disabled { + opacity: 0.7; + } + } + } + } + + > .form { + max-width: 500px; + margin: 0 auto; + + > .preview { + padding: 16px; + } + + > .with-quote { + margin: 0 0 8px 0; + color: var(--accent); + + > button { + padding: 4px 8px; + color: var(--accentAlpha04); + + &:hover { + color: var(--accentAlpha06); + } + + &:active { + color: var(--accentDarken30); + } + } + } + + > .to-specified { + padding: 6px 24px; + margin-bottom: 8px; + overflow: auto; + white-space: nowrap; + + @media (max-width: 500px) { + padding: 6px 16px; + } + + > .visibleUsers { + display: inline; + top: -1px; + font-size: 14px; + + > button { + padding: 4px; + border-radius: 8px; + } + + > span { + margin-right: 14px; + padding: 8px 0 8px 8px; + border-radius: 8px; + background: var(--nwjktjjq); + + > button { + padding: 4px 8px; + } + } + } + } + + > input { + z-index: 1; + } + + > input, + > textarea { + display: block; + box-sizing: border-box; + padding: 0 24px; + margin: 0; + width: 100%; + font-size: 16px; + border: none; + border-radius: 0; + background: transparent; + color: var(--fg); + font-family: initial; + + @media (max-width: 500px) { + padding: 0 16px; + } + + &:focus { + outline: none; + } + + &:disabled { + opacity: 0.5; + } + } + + > textarea { + max-width: 100%; + min-width: 100%; + min-height: 90px; + + @media (max-width: 500px) { + min-height: 80px; + } + } + + > .mk-uploader { + margin: 8px 0 0 0; + padding: 8px; + } + + > .file { + display: none; + } + + > footer { + padding: 0 16px 16px 16px; + + @media (max-width: 500px) { + padding: 0 8px 8px 8px; + } + + > * { + display: inline-block; + padding: 0; + margin: 0; + font-size: 16px; + width: 48px; + height: 48px; + border-radius: 6px; + + &:hover { + background: var(--geavgsxy); + } + } + } + } +} +</style> diff --git a/src/client/components/reaction-icon.vue b/src/client/components/reaction-icon.vue new file mode 100644 index 0000000000..368ddc0efc --- /dev/null +++ b/src/client/components/reaction-icon.vue @@ -0,0 +1,32 @@ +<template> +<mk-emoji :emoji="reaction.startsWith(':') ? null : reaction" :name="reaction.startsWith(':') ? reaction.substr(1, reaction.length - 2) : null" :is-reaction="true" :custom-emojis="customEmojis" :normal="true" :no-style="noStyle"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +export default Vue.extend({ + i18n, + props: { + reaction: { + type: String, + required: true + }, + noStyle: { + type: Boolean, + required: false, + default: false + }, + }, + data() { + return { + customEmojis: [] + }; + }, + created() { + this.$root.getMeta().then(meta => { + if (meta && meta.emojis) this.customEmojis = meta.emojis; + }); + }, +}); +</script> diff --git a/src/client/components/reaction-picker.vue b/src/client/components/reaction-picker.vue new file mode 100644 index 0000000000..00b964f07c --- /dev/null +++ b/src/client/components/reaction-picker.vue @@ -0,0 +1,229 @@ +<template> +<x-popup :source="source" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }" v-hotkey.global="keymap"> + <div class="rdfaahpb"> + <transition-group + name="reaction-fade" + tag="div" + class="buttons" + ref="buttons" + :class="{ showFocus }" + :css="false" + @before-enter="beforeEnter" + @enter="enter" + mode="out-in" + appear + > + <button class="_button" v-for="(reaction, i) in rs" :key="reaction" @click="react(reaction)" :data-index="i" :tabindex="i + 1" :title="/^[a-z]+$/.test(reaction) ? $t('@.reactions.' + reaction) : reaction"><x-reaction-icon :reaction="reaction"/></button> + </transition-group> + <input class="text" v-model="text" :placeholder="$t('enterEmoji')" @keyup.enter="reactText" @input="tryReactText" v-autocomplete="{ model: 'text' }"> + </div> +</x-popup> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import { emojiRegex } from '../../misc/emoji-regex'; +import XReactionIcon from './reaction-icon.vue'; +import XPopup from './popup.vue'; + +export default Vue.extend({ + i18n, + + components: { + XPopup, + XReactionIcon, + }, + + props: { + source: { + required: true + }, + + reactions: { + required: false + }, + + showFocus: { + type: Boolean, + required: false, + default: false + }, + }, + + data() { + return { + rs: this.reactions || this.$store.state.settings.reactions, + text: null, + focus: null + }; + }, + + computed: { + keymap(): any { + return { + 'esc': this.close, + 'enter|space|plus': this.choose, + 'up|k': this.focusUp, + 'left|h|shift+tab': this.focusLeft, + 'right|l|tab': this.focusRight, + 'down|j': this.focusDown, + '1': () => this.react(this.rs[0]), + '2': () => this.react(this.rs[1]), + '3': () => this.react(this.rs[2]), + '4': () => this.react(this.rs[3]), + '5': () => this.react(this.rs[4]), + '6': () => this.react(this.rs[5]), + '7': () => this.react(this.rs[6]), + '8': () => this.react(this.rs[7]), + '9': () => this.react(this.rs[8]), + '0': () => this.react(this.rs[9]), + }; + }, + }, + + watch: { + focus(i) { + this.$refs.buttons.children[i].elm.focus(); + } + }, + + mounted() { + this.focus = 0; + }, + + methods: { + close() { + this.$refs.popup.close(); + }, + + react(reaction) { + this.$emit('chosen', reaction); + }, + + reactText() { + if (!this.text) return; + this.react(this.text); + }, + + tryReactText() { + if (!this.text) return; + if (!this.text.match(emojiRegex)) return; + this.reactText(); + }, + + focusUp() { + this.focus = this.focus == 0 ? 9 : this.focus < 5 ? (this.focus + 4) : (this.focus - 5); + }, + + focusDown() { + this.focus = this.focus == 9 ? 0 : this.focus >= 5 ? (this.focus - 4) : (this.focus + 5); + }, + + focusRight() { + this.focus = this.focus == 9 ? 0 : (this.focus + 1); + }, + + focusLeft() { + this.focus = this.focus == 0 ? 9 : (this.focus - 1); + }, + + choose() { + this.$refs.buttons.children[this.focus].elm.click(); + }, + + beforeEnter(el) { + el.style.opacity = 0; + el.style.transform = 'scale(0.7)'; + }, + + enter(el, done) { + el.style.transition = [getComputedStyle(el).transition, 'transform 1s cubic-bezier(0.23, 1, 0.32, 1)', 'opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1)'].filter(x => x != '').join(','); + setTimeout(() => { + el.style.opacity = 1; + el.style.transform = 'scale(1)'; + setTimeout(done, 1000); + }, 0 * el.dataset.index) + }, + } +}); +</script> + +<style lang="scss" scoped> +.rdfaahpb { + > .buttons { + padding: 6px 6px 0 6px; + width: 212px; + box-sizing: border-box; + text-align: center; + + @media (max-width: 1025px) { + padding: 8px 8px 0 8px; + width: 256px; + } + + &.showFocus { + > button:focus { + z-index: 1; + + &:after { + content: ""; + pointer-events: none; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + border: 2px solid var(--focus); + border-radius: 4px; + } + } + } + + > button { + padding: 0; + width: 40px; + height: 40px; + font-size: 24px; + border-radius: 2px; + + @media (max-width: 1025px) { + width: 48px; + height: 48px; + font-size: 26px; + } + + > * { + height: 1em; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:active { + background: var(--accent); + box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); + } + } + } + + > .text { + width: 208px; + padding: 8px; + margin: 0 0 6px 0; + box-sizing: border-box; + text-align: center; + font-size: 16px; + outline: none; + border: none; + background: transparent; + color: var(--fg); + + @media (max-width: 1025px) { + width: 256px; + margin: 4px 0 8px 0; + } + } +} +</style> diff --git a/src/client/components/reactions-viewer.details.vue b/src/client/components/reactions-viewer.details.vue new file mode 100644 index 0000000000..ea2523a11f --- /dev/null +++ b/src/client/components/reactions-viewer.details.vue @@ -0,0 +1,117 @@ +<template> +<transition name="zoom-in-top"> + <div class="buebdbiu" ref="popover" v-if="show"> + <template v-if="users.length <= 10"> + <b v-for="u in users" :key="u.id" style="margin-right: 12px;"> + <mk-avatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/> + <mk-user-name :user="u" :nowrap="false" style="line-height: 24px;"/> + </b> + </template> + <template v-if="10 < users.length"> + <b v-for="u in users" :key="u.id" style="margin-right: 12px;"> + <mk-avatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/> + <mk-user-name :user="u" :nowrap="false" style="line-height: 24px;"/> + </b> + <span slot="omitted">+{{ count - 10 }}</span> + </template> + </div> +</transition> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; + +export default Vue.extend({ + i18n, + props: { + reaction: { + type: String, + required: true, + }, + users: { + type: Array, + required: true, + }, + count: { + type: Number, + required: true, + }, + source: { + required: true, + } + }, + data() { + return { + show: false + }; + }, + mounted() { + this.show = true; + + this.$nextTick(() => { + const popover = this.$refs.popover as any; + + if (this.source == null) { + this.destroyDom(); + return; + } + const rect = this.source.getBoundingClientRect(); + + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + this.source.offsetHeight; + popover.style.left = (x - 28) + 'px'; + popover.style.top = (y + 16) + 'px'; + }); + } + methods: { + close() { + this.show = false; + setTimeout(this.destroyDom, 300); + } + } +}) +</script> + +<style lang="scss" scoped> +.buebdbiu { + z-index: 10000; + display: block; + position: absolute; + max-width: 240px; + font-size: 0.8em; + padding: 6px 8px; + background: var(--panel); + text-align: center; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0,0,0,0.25); + pointer-events: none; + transform-origin: center -16px; + + &:before { + content: ""; + pointer-events: none; + display: block; + position: absolute; + top: -28px; + left: 12px; + border-top: solid 14px transparent; + border-right: solid 14px transparent; + border-bottom: solid 14px rgba(0,0,0,0.1); + border-left: solid 14px transparent; + } + + &:after { + content: ""; + pointer-events: none; + display: block; + position: absolute; + top: -27px; + left: 12px; + border-top: solid 14px transparent; + border-right: solid 14px transparent; + border-bottom: solid 14px var(--panel); + border-left: solid 14px transparent; + } +} +</style> diff --git a/src/client/app/common/views/components/reactions-viewer.reaction.vue b/src/client/components/reactions-viewer.reaction.vue index dade012c29..a878a283ff 100644 --- a/src/client/app/common/views/components/reactions-viewer.reaction.vue +++ b/src/client/components/reactions-viewer.reaction.vue @@ -1,26 +1,27 @@ <template> <span - class="reaction" + class="reaction _button" :class="{ reacted: note.myReaction == reaction }" @click="toggleReaction(reaction)" v-if="count > 0" - v-particle="!isMe" @mouseover="onMouseover" @mouseleave="onMouseleave" ref="reaction" > - <mk-reaction-icon :reaction="reaction" ref="icon"/> + <x-reaction-icon :reaction="reaction" ref="icon"/> <span>{{ count }}</span> </span> </template> <script lang="ts"> import Vue from 'vue'; -import Icon from './reaction-icon.vue'; -import anime from 'animejs'; import XDetails from './reactions-viewer.details.vue'; +import XReactionIcon from './reaction-icon.vue'; export default Vue.extend({ + components: { + XReactionIcon + }, props: { reaction: { type: String, @@ -126,83 +127,41 @@ export default Vue.extend({ } }, anime() { - if (this.$store.state.device.reduceMotion) return; if (document.hidden) return; - this.$nextTick(() => { - if (this.$refs.icon == null) return; - - const rect = this.$refs.icon.$el.getBoundingClientRect(); - - const x = rect.left; - const y = rect.top; - - const icon = new Icon({ - parent: this, - propsData: { - reaction: this.reaction - } - }).$mount(); - - icon.$el.style.position = 'absolute'; - icon.$el.style.zIndex = 100; - icon.$el.style.top = (y + window.scrollY) + 'px'; - icon.$el.style.left = (x + window.scrollX) + 'px'; - icon.$el.style.fontSize = window.getComputedStyle(this.$refs.icon.$el).fontSize; - - document.body.appendChild(icon.$el); - - anime({ - targets: icon.$el, - opacity: [1, 0], - translateY: [0, -64], - duration: 1000, - easing: 'linear', - complete: () => { - icon.destroyDom(); - } - }); - }); + // TODO }, } }); </script> -<style lang="stylus" scoped> -.reaction - display inline-block - height 32px - margin 2px - padding 0 6px - border-radius 4px - cursor pointer - - &, * - -webkit-touch-callout none - -webkit-user-select none - -khtml-user-select none - -moz-user-select none - -ms-user-select none - user-select none +<style lang="scss" scoped> +.reaction { + display: inline-block; + height: 32px; + margin: 2px; + padding: 0 6px; + border-radius: 4px; - * - user-select none - pointer-events none + &.reacted { + background: var(--accent); - &.reacted - background var(--primary) - - > span - color var(--primaryForeground) + > span { + color: #fff; + } + } - &:not(.reacted) - background var(--reactionViewerButtonBg) + &:not(.reacted) { + background: rgba(0, 0, 0, 0.05); - &:hover - background var(--reactionViewerButtonHoverBg) + &:hover { + background: rgba(0, 0, 0, 0.1); + } + } - > span - font-size 1.1em - line-height 32px - color var(--text) + > span { + font-size: 0.9em; + line-height: 32px; + } +} </style> diff --git a/src/client/app/common/views/components/reactions-viewer.vue b/src/client/components/reactions-viewer.vue index 9701d2481a..d089cf682c 100644 --- a/src/client/app/common/views/components/reactions-viewer.vue +++ b/src/client/components/reactions-viewer.vue @@ -31,17 +31,18 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.mk-reactions-viewer - margin: 4px -2px +<style lang="scss" scoped> +.mk-reactions-viewer { + margin: 4px -2px 0 -2px; - &:empty - display none + &:empty { + display: none; + } - &.isMe - > span - cursor default !important - - &:hover - background var(--reactionViewerButtonBg) !important + &.isMe { + > span { + cursor: default !important; + } + } +} </style> diff --git a/src/client/components/renote-picker.vue b/src/client/components/renote-picker.vue new file mode 100644 index 0000000000..d8258d5f5d --- /dev/null +++ b/src/client/components/renote-picker.vue @@ -0,0 +1,94 @@ +<template> +<x-popup :source="source" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }" v-hotkey.global="keymap"> + <div class="rdfaahpc"> + <button class="_button" @click="renote()"><fa :icon="faRetweet"/></button> + <button class="_button" @click="quote()"><fa :icon="faQuoteRight"/></button> + </div> +</x-popup> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faQuoteRight, faRetweet } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import XPopup from './popup.vue'; + +export default Vue.extend({ + i18n, + + components: { + XPopup, + }, + + props: { + note: { + type: Object, + required: true + }, + + source: { + required: true + }, + }, + + data() { + return { + faQuoteRight, faRetweet + }; + }, + + computed: { + keymap(): any { + return { + 'esc': this.close, + }; + } + }, + + methods: { + renote() { + (this as any).$root.api('notes/create', { + renoteId: this.note.id + }).then(() => { + this.$emit('closed'); + this.destroyDom(); + }); + }, + + quote() { + this.$emit('closed'); + this.destroyDom(); + this.$root.post({ + renote: this.note, + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.rdfaahpc { + padding: 4px; + + > button { + padding: 0; + width: 40px; + height: 40px; + font-size: 16px; + border-radius: 2px; + + > * { + height: 1em; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:active { + background: var(--accent); + box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); + } + } +} +</style> diff --git a/src/client/components/sequential-entrance.vue b/src/client/components/sequential-entrance.vue new file mode 100644 index 0000000000..70e486719e --- /dev/null +++ b/src/client/components/sequential-entrance.vue @@ -0,0 +1,63 @@ +<template> +<transition-group + name="staggered-fade" + tag="div" + :css="false" + @before-enter="beforeEnter" + @enter="enter" + @leave="leave" + mode="out-in" + appear +> + <slot></slot> +</transition-group> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + delay: { + type: Number, + required: false, + default: 40 + }, + direction: { + type: String, + required: false, + default: 'down' + } + }, + methods: { + beforeEnter(el) { + el.style.opacity = 0; + el.style.transform = this.direction === 'down' ? 'translateY(-64px)' : 'translateY(64px)'; + }, + enter(el, done) { + el.style.transition = [getComputedStyle(el).transition, 'transform 0.7s cubic-bezier(0.23, 1, 0.32, 1)', 'opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1)'].filter(x => x != '').join(','); + setTimeout(() => { + el.style.opacity = 1; + el.style.transform = 'translateY(0px)'; + setTimeout(done, 700); + }, this.delay * el.dataset.index) + }, + leave(el, done) { + setTimeout(() => { + el.style.opacity = 0; + el.style.transform = this.direction === 'down' ? 'translateY(64px)' : 'translateY(-64px)'; + setTimeout(done, 700); + }, this.delay * el.dataset.index) + }, + focus() { + this.$slots.default[0].elm.focus(); + } + } +}); +</script> + +<style lang="scss"> +.staggered-fade-move { + transition: transform 0.7s !important; +} +</style> diff --git a/src/client/components/signin-dialog.vue b/src/client/components/signin-dialog.vue new file mode 100644 index 0000000000..dbc63c93bf --- /dev/null +++ b/src/client/components/signin-dialog.vue @@ -0,0 +1,37 @@ +<template> +<x-window ref="window" @closed="() => { $emit('closed'); destroyDom(); }"> + <template #header>{{ $t('login') }}</template> + <x-signin :auto-set="autoSet" @login="onLogin"/> +</x-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import XWindow from './window.vue'; +import XSignin from './signin.vue'; + +export default Vue.extend({ + i18n, + + components: { + XSignin, + XWindow, + }, + + props: { + autoSet: { + type: Boolean, + required: false, + default: false, + } + }, + + methods: { + onLogin(res) { + this.$emit('login', res); + this.$refs.window.close(); + } + } +}); +</script> diff --git a/src/client/app/common/views/components/signin.vue b/src/client/components/signin.vue index bb4a6605bd..dc6fad1c5d 100644 --- a/src/client/app/common/views/components/signin.vue +++ b/src/client/components/signin.vue @@ -1,17 +1,17 @@ <template> -<form class="mk-signin" :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> +<form class="eppvobhk" :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> <div class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null }" v-show="withAvatar"></div> <div class="normal-signin" v-if="!totpLogin"> - <ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange"> + <mk-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange"> <span>{{ $t('username') }}</span> <template #prefix>@</template> <template #suffix>@{{ host }}</template> - </ui-input> - <ui-input v-model="password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required> + </mk-input> + <mk-input v-model="password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required> <span>{{ $t('password') }}</span> - <template #prefix><fa icon="lock"/></template> - </ui-input> - <ui-button type="submit" :disabled="signing">{{ signing ? $t('signing-in') : $t('@.signin') }}</ui-button> + <template #prefix><fa :icon="faLock"/></template> + </mk-input> + <mk-button type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? $t('loggingIn') : $t('login') }}</mk-button> <p v-if="meta && meta.enableTwitterIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/twitter`"><fa :icon="['fab', 'twitter']"/> {{ $t('signin-with-twitter') }}</a></p> <p v-if="meta && meta.enableGithubIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/github`"><fa :icon="['fab', 'github']"/> {{ $t('signin-with-github') }}</a></p> <p v-if="meta && meta.enableDiscordIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/discord`"><fa :icon="['fab', 'discord']"/> {{ $t('signin-with-discord') /* TODO: Make these layouts better */ }}</a></p> @@ -19,24 +19,24 @@ <div class="2fa-signin" v-if="totpLogin" :class="{ securityKeys: user && user.securityKeys }"> <div v-if="user && user.securityKeys" class="twofa-group tap-group"> <p>{{ $t('tap-key') }}</p> - <ui-button @click="queryKey" v-if="!queryingKey"> + <mk-button @click="queryKey" v-if="!queryingKey"> {{ $t('@.error.retry') }} - </ui-button> + </mk-button> </div> <div class="or-hr" v-if="user && user.securityKeys"> <p class="or-msg">{{ $t('or') }}</p> </div> <div class="twofa-group totp-group"> - <p style="margin-bottom:0;">{{ $t('enter-2fa-code') }}</p> - <ui-input v-model="password" type="password" :with-password-toggle="true" v-if="user && user.usePasswordLessLogin" required> + <p style="margin-bottom:0;">{{ $t('twoStepAuthentication') }}</p> + <mk-input v-model="password" type="password" :with-password-toggle="true" v-if="user && user.usePasswordLessLogin" required> <span>{{ $t('password') }}</span> - <template #prefix><fa icon="lock"/></template> - </ui-input> - <ui-input v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required> - <span>{{ $t('@.2fa') }}</span> - <template #prefix><fa icon="gavel"/></template> - </ui-input> - <ui-button type="submit" :disabled="signing">{{ signing ? $t('signing-in') : $t('@.signin') }}</ui-button> + <template #prefix><fa :icon="faLock"/></template> + </mk-input> + <mk-input v-model="token" type="number" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required> + <span>{{ $t('token') }}</span> + <template #prefix><fa :icon="faGavel"/></template> + </mk-input> + <mk-button type="submit" :disabled="signing" primary style="margin: 0 auto;">{{ signing ? $t('loggingIn') : $t('login') }}</mk-button> </div> </div> </form> @@ -44,19 +44,32 @@ <script lang="ts"> import Vue from 'vue'; -import i18n from '../../../i18n'; -import { apiUrl, host } from '../../../config'; import { toUnicode } from 'punycode'; -import { hexifyAB } from '../../scripts/2fa'; +import { faLock, faGavel } from '@fortawesome/free-solid-svg-icons'; +import MkButton from './ui/button.vue'; +import MkInput from './ui/input.vue'; +import i18n from '../i18n'; +import { apiUrl, host } from '../config'; +import { hexifyAB } from '../scripts/2fa'; export default Vue.extend({ - i18n: i18n('common/views/components/signin.vue'), + i18n, + + components: { + MkButton, + MkInput, + }, props: { withAvatar: { type: Boolean, required: false, default: true + }, + autoSet: { + type: Boolean, + required: false, + default: false, } }, @@ -74,6 +87,7 @@ export default Vue.extend({ credential: null, challengeData: null, queryingKey: false, + faLock, faGavel }; }, @@ -81,6 +95,13 @@ export default Vue.extend({ this.$root.getMeta().then(meta => { this.meta = meta; }); + + if (this.autoSet) { + this.$once('login', res => { + localStorage.setItem('i', res.i); + location.reload(); + }); + } }, methods: { @@ -127,8 +148,7 @@ export default Vue.extend({ challengeId: this.challengeData.challengeId }); }).then(res => { - localStorage.setItem('i', res.i); - location.reload(); + this.$emit('login', res); }).catch(err => { if (err === null) return; this.$root.dialog({ @@ -141,7 +161,6 @@ export default Vue.extend({ onSubmit() { this.signing = true; - if (!this.totpLogin && this.user && this.user.twoFactorEnabled) { if (window.PublicKeyCredential && this.user.securityKeys) { this.$root.api('signin', { @@ -171,12 +190,11 @@ export default Vue.extend({ password: this.password, token: this.user && this.user.twoFactorEnabled ? this.token : undefined }).then(res => { - localStorage.setItem('i', res.i); - location.reload(); + this.$emit('login', res); }).catch(() => { this.$root.dialog({ type: 'error', - text: this.$t('login-failed') + text: this.$t('loginFailed') }); this.signing = false; }); @@ -186,63 +204,16 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.mk-signin - color #555 - - .or-hr, - .or-hr .or-msg, - .twofa-group, - .twofa-group p - color var(--text) - - .tap-group > button - margin-bottom 1em - - .securityKeys .or-hr - & - position relative - - .or-msg - &:before - right 100% - margin-right 0.125em - - &:after - left 100% - margin-left 0.125em - - &:before, &:after - content "" - position absolute - top 50% - width 100% - height 2px - background #555 - - & - position relative - margin auto - left 0 - right 0 - top 0 - bottom 0 - font-size 1.5em - height 1.5em - width 3em - text-align center - - &.signing - &, * - cursor wait !important - - > .avatar - margin 0 auto 0 auto - width 64px - height 64px - background #ddd - background-position center - background-size cover - border-radius 100% - +<style lang="scss" scoped> +.eppvobhk { + > .avatar { + margin: 0 auto 0 auto; + width: 64px; + height: 64px; + background: #ddd; + background-position: center; + background-size: cover; + border-radius: 100%; + } +} </style> diff --git a/src/client/components/signup-dialog.vue b/src/client/components/signup-dialog.vue new file mode 100644 index 0000000000..76421d44ec --- /dev/null +++ b/src/client/components/signup-dialog.vue @@ -0,0 +1,22 @@ +<template> +<x-window @closed="() => { $emit('closed'); destroyDom(); }"> + <template #header>{{ $t('signup') }}</template> + <x-signup/> +</x-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import XWindow from './window.vue'; +import XSignup from './signup.vue'; + +export default Vue.extend({ + i18n, + + components: { + XSignup, + XWindow, + }, +}); +</script> diff --git a/src/client/app/common/views/components/signup.vue b/src/client/components/signup.vue index 893f6575fb..c03a99def6 100644 --- a/src/client/app/common/views/components/signup.vue +++ b/src/client/components/signup.vue @@ -1,62 +1,72 @@ <template> <form class="mk-signup" @submit.prevent="onSubmit" :autocomplete="Math.random()"> <template v-if="meta"> - <ui-input v-if="meta.disableRegistration" v-model="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required styl="fill"> + <mk-input v-if="meta.disableRegistration" v-model="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required> <span>{{ $t('invitation-code') }}</span> <template #prefix><fa icon="id-card-alt"/></template> <template #desc v-html="this.$t('invitation-info').replace('{}', 'mailto:' + meta.maintainerEmail)"></template> - </ui-input> - <ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @input="onChangeUsername" styl="fill"> + </mk-input> + <mk-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @input="onChangeUsername"> <span>{{ $t('username') }}</span> <template #prefix>@</template> <template #suffix>@{{ host }}</template> <template #desc> - <span v-if="usernameState == 'wait'" style="color:#999"><fa icon="spinner" pulse fixed-width/> {{ $t('checking') }}</span> - <span v-if="usernameState == 'ok'" style="color:#3CB7B5"><fa icon="check" fixed-width/> {{ $t('available') }}</span> - <span v-if="usernameState == 'unavailable'" style="color:#FF1161"><fa icon="exclamation-triangle" fixed-width/> {{ $t('unavailable') }}</span> - <span v-if="usernameState == 'error'" style="color:#FF1161"><fa icon="exclamation-triangle" fixed-width/> {{ $t('error') }}</span> - <span v-if="usernameState == 'invalid-format'" style="color:#FF1161"><fa icon="exclamation-triangle" fixed-width/> {{ $t('invalid-format') }}</span> - <span v-if="usernameState == 'min-range'" style="color:#FF1161"><fa icon="exclamation-triangle" fixed-width/> {{ $t('too-short') }}</span> - <span v-if="usernameState == 'max-range'" style="color:#FF1161"><fa icon="exclamation-triangle" fixed-width/> {{ $t('too-long') }}</span> + <span v-if="usernameState == 'wait'" style="color:#999"><fa :icon="faSpinner" pulse fixed-width/> {{ $t('checking') }}</span> + <span v-if="usernameState == 'ok'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('available') }}</span> + <span v-if="usernameState == 'unavailable'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('unavailable') }}</span> + <span v-if="usernameState == 'error'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('error') }}</span> + <span v-if="usernameState == 'invalid-format'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('invalid-format') }}</span> + <span v-if="usernameState == 'min-range'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('too-short') }}</span> + <span v-if="usernameState == 'max-range'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('too-long') }}</span> </template> - </ui-input> - <ui-input v-model="password" type="password" :autocomplete="Math.random()" required @input="onChangePassword" :with-password-meter="true" styl="fill"> + </mk-input> + <mk-input v-model="password" type="password" :autocomplete="Math.random()" required @input="onChangePassword"> <span>{{ $t('password') }}</span> - <template #prefix><fa icon="lock"/></template> + <template #prefix><fa :icon="faLock"/></template> <template #desc> - <p v-if="passwordStrength == 'low'" style="color:#FF1161"><fa icon="exclamation-triangle" fixed-width/> {{ $t('weak-password') }}</p> - <p v-if="passwordStrength == 'medium'" style="color:#3CB7B5"><fa icon="check" fixed-width/> {{ $t('normal-password') }}</p> - <p v-if="passwordStrength == 'high'" style="color:#3CB7B5"><fa icon="check" fixed-width/> {{ $t('strong-password') }}</p> + <p v-if="passwordStrength == 'low'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('weak-password') }}</p> + <p v-if="passwordStrength == 'medium'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('normal-password') }}</p> + <p v-if="passwordStrength == 'high'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('strong-password') }}</p> </template> - </ui-input> - <ui-input v-model="retypedPassword" type="password" :autocomplete="Math.random()" required @input="onChangePasswordRetype" styl="fill"> + </mk-input> + <mk-input v-model="retypedPassword" type="password" :autocomplete="Math.random()" required @input="onChangePasswordRetype"> <span>{{ $t('password') }} ({{ $t('retype') }})</span> - <template #prefix><fa icon="lock"/></template> + <template #prefix><fa :icon="faLock"/></template> <template #desc> - <p v-if="passwordRetypeState == 'match'" style="color:#3CB7B5"><fa icon="check" fixed-width/> {{ $t('password-matched') }}</p> - <p v-if="passwordRetypeState == 'not-match'" style="color:#FF1161"><fa icon="exclamation-triangle" fixed-width/> {{ $t('password-not-matched') }}</p> + <p v-if="passwordRetypeState == 'match'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('password-matched') }}</p> + <p v-if="passwordRetypeState == 'not-match'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('password-not-matched') }}</p> </template> - </ui-input> - <ui-switch v-model="ToSAgreement" v-if="meta.ToSUrl"> - <i18n path="agree-to"> - <a :href="meta.ToSUrl" target="_blank">{{ $t('tos') }}</a> + </mk-input> + <mk-switch v-model="ToSAgreement" v-if="meta.tosUrl"> + <i18n path="agreeTo"> + <a :href="meta.tosUrl" target="_blank">{{ $t('tos') }}</a> </i18n> - </ui-switch> + </mk-switch> <div v-if="meta.enableRecaptcha" class="g-recaptcha" :data-sitekey="meta.recaptchaSiteKey" style="margin: 16px 0;"></div> - <ui-button type="submit" :disabled=" submitting || !(meta.ToSUrl ? ToSAgreement : true) || passwordRetypeState == 'not-match'">{{ $t('create') }}</ui-button> + <mk-button type="submit" :disabled=" submitting || !(meta.tosUrl ? ToSAgreement : true) || passwordRetypeState == 'not-match'" primary>{{ $t('start') }}</mk-button> </template> </form> </template> <script lang="ts"> import Vue from 'vue'; -import i18n from '../../../i18n'; +import { faLock, faExclamationTriangle, faSpinner, faCheck } from '@fortawesome/free-solid-svg-icons'; const getPasswordStrength = require('syuilo-password-strength'); -import { host, url } from '../../../config'; import { toUnicode } from 'punycode'; +import i18n from '../i18n'; +import { host, url } from '../config'; +import MkButton from './ui/button.vue'; +import MkInput from './ui/input.vue'; +import MkSwitch from './ui/switch.vue'; export default Vue.extend({ - i18n: i18n('common/views/components/signup.vue'), + i18n, + + components: { + MkButton, + MkInput, + MkSwitch, + }, data() { return { @@ -71,7 +81,8 @@ export default Vue.extend({ passwordRetypeState: null, meta: {}, submitting: false, - ToSAgreement: false + ToSAgreement: false, + faLock, faExclamationTriangle, faSpinner, faCheck } }, @@ -178,8 +189,3 @@ export default Vue.extend({ } }); </script> - -<style lang="stylus" scoped> -.mk-signup - min-width 302px -</style> diff --git a/src/client/components/sub-note-content.vue b/src/client/components/sub-note-content.vue new file mode 100644 index 0000000000..e60c197442 --- /dev/null +++ b/src/client/components/sub-note-content.vue @@ -0,0 +1,65 @@ +<template> +<div class="wrmlmaau"> + <div class="body"> + <span v-if="note.isHidden" style="opacity: 0.5">({{ $t('private') }})</span> + <span v-if="note.deletedAt" style="opacity: 0.5">({{ $t('deleted') }})</span> + <router-link class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><fa :icon="faReply"/></router-link> + <mfm v-if="note.text" :text="note.text" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis"/> + <router-link class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</router-link> + </div> + <details v-if="note.files.length > 0"> + <summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary> + <x-media-list :media-list="note.files"/> + </details> + <details v-if="note.poll"> + <summary>{{ $t('poll') }}</summary> + <x-poll :note="note"/> + </details> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faReply } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import XPoll from './poll.vue'; +import XMediaList from './media-list.vue'; + +export default Vue.extend({ + i18n, + components: { + XPoll, + XMediaList, + }, + props: { + note: { + type: Object, + required: true + } + }, + data() { + return { + faReply + }; + } +}); +</script> + +<style lang="scss" scoped> +.wrmlmaau { + overflow-wrap: break-word; + + > .body { + > .reply { + margin-right: 6px; + color: var(--accent); + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + } +} +</style> diff --git a/src/client/app/common/views/components/time.vue b/src/client/components/time.vue index 8cfcc4cb4f..922067b4d5 100644 --- a/src/client/app/common/views/components/time.vue +++ b/src/client/components/time.vue @@ -1,17 +1,17 @@ <template> <time class="mk-time" :title="absolute"> - <span v-if=" mode == 'relative' ">{{ relative }}</span> - <span v-if=" mode == 'absolute' ">{{ absolute }}</span> - <span v-if=" mode == 'detail' ">{{ absolute }} ({{ relative }})</span> + <span v-if="mode == 'relative'">{{ relative }}</span> + <span v-if="mode == 'absolute'">{{ absolute }}</span> + <span v-if="mode == 'detail'">{{ absolute }} ({{ relative }})</span> </time> </template> <script lang="ts"> import Vue from 'vue'; -import i18n from '../../../i18n'; +import i18n from '../i18n'; export default Vue.extend({ - i18n: i18n(), + i18n, props: { time: { type: [Date, String], @@ -39,15 +39,15 @@ export default Vue.extend({ const time = this._time; const ago = (this.now.getTime() - time.getTime()) / 1000/*ms*/; return ( - ago >= 31536000 ? this.$t('@.time.years_ago') .replace('{}', (~~(ago / 31536000)).toString()) : - ago >= 2592000 ? this.$t('@.time.months_ago') .replace('{}', (~~(ago / 2592000)).toString()) : - ago >= 604800 ? this.$t('@.time.weeks_ago') .replace('{}', (~~(ago / 604800)).toString()) : - ago >= 86400 ? this.$t('@.time.days_ago') .replace('{}', (~~(ago / 86400)).toString()) : - ago >= 3600 ? this.$t('@.time.hours_ago') .replace('{}', (~~(ago / 3600)).toString()) : - ago >= 60 ? this.$t('@.time.minutes_ago').replace('{}', (~~(ago / 60)).toString()) : - ago >= 10 ? this.$t('@.time.seconds_ago').replace('{}', (~~(ago % 60)).toString()) : - ago >= -1 ? this.$t('@.time.just_now') : - ago < -1 ? this.$t('@.time.future') : + ago >= 31536000 ? this.$t('_ago.yearsAgo', { n: (~~(ago / 31536000)).toString() }) : + ago >= 2592000 ? this.$t('_ago.monthsAgo', { n: (~~(ago / 2592000)).toString() }) : + ago >= 604800 ? this.$t('_ago.weeksAgo', { n: (~~(ago / 604800)).toString() }) : + ago >= 86400 ? this.$t('_ago.daysAgo', { n: (~~(ago / 86400)).toString() }) : + ago >= 3600 ? this.$t('_ago.hoursAgo', { n: (~~(ago / 3600)).toString() }) : + ago >= 60 ? this.$t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) : + ago >= 10 ? this.$t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) : + ago >= -1 ? this.$t('_ago.justNow') : + ago < -1 ? this.$t('_ago.future') : this.$t('@.time.unknown')); } }, diff --git a/src/client/components/timeline.vue b/src/client/components/timeline.vue new file mode 100644 index 0000000000..f5edb18550 --- /dev/null +++ b/src/client/components/timeline.vue @@ -0,0 +1,118 @@ +<template> +<x-notes ref="tl" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNotes from './notes.vue'; + +export default Vue.extend({ + components: { + XNotes + }, + + props: { + src: { + type: String, + required: true + }, + list: { + required: false + }, + antenna: { + required: false + } + }, + + data() { + return { + connection: null, + pagination: null, + baseQuery: { + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes + }, + query: {}, + }; + }, + + created() { + this.$once('hook:beforeDestroy', () => { + this.connection.dispose(); + }); + + const prepend = note => { + (this.$refs.tl as any).prepend(note); + }; + + const onUserAdded = () => { + (this.$refs.tl as any).reload(); + }; + + const onUserRemoved = () => { + (this.$refs.tl as any).reload(); + }; + + let endpoint; + + if (this.src == 'antenna') { + endpoint = 'antennas/notes'; + this.query = { + antennaId: this.antenna.id + }; + this.connection = this.$root.stream.connectToChannel('antenna', { + antennaId: this.antenna.id + }); + this.connection.on('note', prepend); + } else if (this.src == 'home') { + endpoint = 'notes/timeline'; + const onChangeFollowing = () => { + this.fetch(); + }; + this.connection = this.$root.stream.useSharedConnection('homeTimeline'); + this.connection.on('note', prepend); + this.connection.on('follow', onChangeFollowing); + this.connection.on('unfollow', onChangeFollowing); + } else if (this.src == 'local') { + endpoint = 'notes/local-timeline'; + this.connection = this.$root.stream.useSharedConnection('localTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'social') { + endpoint = 'notes/hybrid-timeline'; + this.connection = this.$root.stream.useSharedConnection('hybridTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'global') { + endpoint = 'notes/global-timeline'; + this.connection = this.$root.stream.useSharedConnection('globalTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'list') { + endpoint = 'notes/user-list-timeline'; + this.query = { + listId: this.list.id + }; + this.connection = this.$root.stream.connectToChannel('userList', { + listId: this.list.id + }); + this.connection.on('note', prepend); + this.connection.on('userAdded', onUserAdded); + this.connection.on('userRemoved', onUserRemoved); + } + + this.pagination = { + endpoint: endpoint, + limit: 10, + params: init => ({ + untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), + ...this.baseQuery, ...this.query + }) + }; + }, + + methods: { + focus() { + this.$refs.tl.focus(); + } + } +}); +</script> diff --git a/src/client/components/toast.vue b/src/client/components/toast.vue new file mode 100644 index 0000000000..fefe91e3bd --- /dev/null +++ b/src/client/components/toast.vue @@ -0,0 +1,76 @@ +<template> +<div class="mk-toast"> + <transition name="notification-slide" appear @after-leave="() => { destroyDom(); }"> + <x-notification :notification="notification" class="notification" v-if="show"/> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNotification from './notification.vue'; + +export default Vue.extend({ + components: { + XNotification + }, + props: { + notification: { + type: Object, + required: true + } + }, + data() { + return { + show: true + }; + }, + mounted() { + setTimeout(() => { + this.show = false; + }, 6000); + } +}); +</script> + +<style lang="scss" scoped> +.notification-slide-enter-active, .notification-slide-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.notification-slide-enter, .notification-slide-leave-to { + opacity: 0; + transform: translateX(-250px); +} + +.mk-toast { + position: fixed; + z-index: 10000; + left: 0; + width: 250px; + top: 32px; + padding: 0 32px; + pointer-events: none; + + @media (max-width: 700px) { + top: initial; + bottom: 112px; + padding: 0 16px; + } + + @media (max-width: 500px) { + bottom: 92px; + padding: 0 8px; + } + + > .notification { + height: 100%; + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + background-color: var(--toastBg); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + border-radius: 8px; + color: var(--toastFg); + overflow: hidden; + } +} +</style> diff --git a/src/client/components/ui/button.vue b/src/client/components/ui/button.vue new file mode 100644 index 0000000000..4071faa1dd --- /dev/null +++ b/src/client/components/ui/button.vue @@ -0,0 +1,204 @@ +<template> +<component class="bghgjjyj _button" + :is="link ? 'a' : 'button'" + :class="{ inline, primary }" + :type="type" + @click="$emit('click', $event)" + @mousedown="onMousedown" +> + <div ref="ripples" class="ripples"></div> + <div class="content"> + <slot></slot> + </div> +</component> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + type: { + type: String, + required: false + }, + primary: { + type: Boolean, + required: false, + default: false + }, + inline: { + type: Boolean, + required: false, + default: false + }, + link: { + type: Boolean, + required: false, + default: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + wait: { + type: Boolean, + required: false, + default: false + }, + }, + mounted() { + if (this.autofocus) { + this.$nextTick(() => { + this.$el.focus(); + }); + } + }, + methods: { + onMousedown(e: MouseEvent) { + function distance(p, q) { + return Math.hypot(p.x - q.x, p.y - q.y); + } + + function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY) { + const origin = {x: circleCenterX, y: circleCenterY}; + const dist1 = distance({x: 0, y: 0}, origin); + const dist2 = distance({x: boxW, y: 0}, origin); + const dist3 = distance({x: 0, y: boxH}, origin); + const dist4 = distance({x: boxW, y: boxH }, origin); + return Math.max(dist1, dist2, dist3, dist4) * 2; + } + + const rect = e.target.getBoundingClientRect(); + + const ripple = document.createElement('div'); + ripple.style.top = (e.clientY - rect.top - 1).toString() + 'px'; + ripple.style.left = (e.clientX - rect.left - 1).toString() + 'px'; + + this.$refs.ripples.appendChild(ripple); + + const circleCenterX = e.clientX - rect.left; + const circleCenterY = e.clientY - rect.top; + + const scale = calcCircleScale(e.target.clientWidth, e.target.clientHeight, circleCenterX, circleCenterY); + + setTimeout(() => { + ripple.style.transform = 'scale(' + (scale / 2) + ')'; + }, 1); + setTimeout(() => { + ripple.style.transition = 'all 1s ease'; + ripple.style.opacity = '0'; + }, 1000); + setTimeout(() => { + if (this.$refs.ripples) this.$refs.ripples.removeChild(ripple); + }, 2000); + } + } +}); +</script> + +<style lang="scss" scoped> +.bghgjjyj { + position: relative; + display: block; + min-width: 100px; + padding: 8px 14px; + text-align: center; + font-weight: normal; + font-size: 14px; + line-height: 24px; + box-shadow: none; + text-decoration: none; + background: var(--buttonBg); + border-radius: 6px; + overflow: hidden; + + &:not(:disabled):hover { + background: var(--buttonHoverBg); + } + + &:not(:disabled):active { + background: var(--buttonHoverBg); + } + + &.primary { + color: #fff; + background: var(--accent); + + &:not(:disabled):hover { + background: var(--jkhztclx); + } + + &:not(:disabled):active { + background: var(--jkhztclx); + } + } + + &:disabled { + opacity: 0.7; + } + + &:focus { + &:after { + content: ""; + pointer-events: none; + position: absolute; + top: -5px; + right: -5px; + bottom: -5px; + left: -5px; + border: 2px solid var(--accentAlpha03); + border-radius: 10px; + } + } + + &.inline + .bghgjjyj { + margin-left: 12px; + } + + &:not(.inline) + .bghgjjyj { + margin-top: 16px; + } + + &.inline { + display: inline-block; + width: auto; + min-width: 100px; + } + + &.primary { + font-weight: bold; + } + + > .ripples { + position: absolute; + z-index: 0; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 6px; + overflow: hidden; + + ::v-deep div { + position: absolute; + width: 2px; + height: 2px; + border-radius: 100%; + background: rgba(0, 0, 0, 0.1); + opacity: 1; + transform: scale(1); + transition: all 0.5s cubic-bezier(0,.5,0,1); + } + } + + &.primary > .ripples ::v-deep div { + background: rgba(0, 0, 0, 0.15); + } + + > .content { + position: relative; + z-index: 1; + } +} +</style> diff --git a/src/client/components/ui/container.vue b/src/client/components/ui/container.vue new file mode 100644 index 0000000000..19820a307d --- /dev/null +++ b/src/client/components/ui/container.vue @@ -0,0 +1,104 @@ +<template> +<div class="ukygtjoj _panel" :class="{ naked, hideHeader: !showHeader }"> + <header v-if="showHeader"> + <div class="title"><slot name="header"></slot></div> + <slot name="func"></slot> + <button class="_button" v-if="bodyTogglable" @click="() => showBody = !showBody"> + <template v-if="showBody"><fa :icon="faAngleUp"/></template> + <template v-else><fa :icon="faAngleDown"/></template> + </button> + </header> + <div v-show="showBody"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + props: { + showHeader: { + type: Boolean, + required: false, + default: true + }, + naked: { + type: Boolean, + required: false, + default: false + }, + bodyTogglable: { + type: Boolean, + required: false, + default: false + }, + expanded: { + type: Boolean, + required: false, + default: true + }, + }, + data() { + return { + showBody: this.expanded, + faAngleUp, faAngleDown + }; + }, + methods: { + toggleContent(show: boolean) { + if (!this.bodyTogglable) return; + this.showBody = show; + } + } +}); +</script> + +<style lang="scss" scoped> +.ukygtjoj { + position: relative; + overflow: hidden; + + & + .ukygtjoj { + margin-top: var(--margin); + } + + &.naked { + background: transparent !important; + box-shadow: none !important; + } + + > header { + position: relative; + + > .title { + margin: 0; + padding: 12px 16px; + + @media (max-width: 500px) { + padding: 8px 10px; + } + + > [data-icon] { + margin-right: 6px; + } + + &:empty { + display: none; + } + } + + > button { + position: absolute; + z-index: 2; + top: 0; + right: 0; + padding: 0; + width: 42px; + height: 100%; + } + } +} +</style> diff --git a/src/client/app/common/views/components/ui/hr.vue b/src/client/components/ui/hr.vue index 38572cfcc3..ae7f7dbf8e 100644 --- a/src/client/app/common/views/components/ui/hr.vue +++ b/src/client/components/ui/hr.vue @@ -7,7 +7,7 @@ import Vue from 'vue'; export default Vue.extend({}); </script> -<style lang="stylus" scoped> +<style lang="scss" scoped> .evrzpitu margin 16px 0 border-bottom solid var(--lineWidth) var(--faceDivider) diff --git a/src/client/components/ui/info.vue b/src/client/components/ui/info.vue new file mode 100644 index 0000000000..3e87fe261d --- /dev/null +++ b/src/client/components/ui/info.vue @@ -0,0 +1,55 @@ +<template> +<div class="fpezltsf" :class="{ warn }"> + <i v-if="warn"><fa :icon="faExclamationTriangle"/></i> + <i v-else><fa :icon="faInfoCircle"/></i> + <slot></slot> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faInfoCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + props: { + warn: { + type: Boolean, + required: false, + default: false + }, + }, + data() { + return { + faInfoCircle, faExclamationTriangle + }; + } +}); +</script> + +<style lang="scss" scoped> +.fpezltsf { + margin: 16px 0; + padding: 16px; + font-size: 90%; + background: var(--infoBg); + color: var(--infoFg); + border-radius: 5px; + + &.warn { + background: var(--infoWarnBg); + color: var(--infoWarnFg); + } + + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + + > i { + margin-right: 4px; + } +} +</style> diff --git a/src/client/components/ui/input.vue b/src/client/components/ui/input.vue new file mode 100644 index 0000000000..69d842ef0f --- /dev/null +++ b/src/client/components/ui/input.vue @@ -0,0 +1,443 @@ +<template> +<div class="juejbjww" :class="{ focused, filled, inline, disabled }"> + <div class="icon" ref="icon"><slot name="icon"></slot></div> + <div class="input"> + <span class="label" ref="label"><slot></slot></span> + <span class="title" ref="title"> + <slot name="title"></slot> + <span class="warning" v-if="invalid"><fa :icon="faExclamationCircle"/>{{ $refs.input.validationMessage }}</span> + </span> + <div class="prefix" ref="prefix"><slot name="prefix"></slot></div> + <template v-if="type != 'file'"> + <input v-if="debounce" ref="input" + v-debounce="500" + :type="type" + v-model.lazy="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)" + @input="onInput" + :list="id" + > + <input v-else ref="input" + :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)" + @input="onInput" + :list="id" + > + <datalist :id="id" v-if="datalist"> + <option v-for="data in datalist" :value="data"/> + </datalist> + </template> + <template v-else> + <input ref="input" + type="text" + :value="filePlaceholder" + readonly + @click="chooseFile" + > + <input ref="file" + type="file" + :value="value" + @change="onChangeFile" + > + </template> + <div class="suffix" ref="suffix"><slot name="suffix"></slot></div> + </div> + <button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $t('save') }}</button> + <div class="desc"><slot name="desc"></slot></div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import debounce from 'v-debounce'; +import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + directives: { + debounce + }, + props: { + value: { + required: false + }, + type: { + type: String, + required: false + }, + required: { + type: Boolean, + required: false + }, + readonly: { + type: Boolean, + required: false + }, + disabled: { + type: Boolean, + required: false + }, + pattern: { + type: String, + required: false + }, + placeholder: { + type: String, + required: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + autocomplete: { + required: false + }, + spellcheck: { + required: false + }, + debounce: { + required: false + }, + datalist: { + type: Array, + required: false, + }, + inline: { + type: Boolean, + required: false, + default: false + }, + save: { + type: Function, + required: false, + }, + }, + data() { + return { + v: this.value, + focused: false, + invalid: false, + changed: false, + id: Math.random().toString(), + faExclamationCircle + }; + }, + computed: { + filled(): boolean { + return this.v !== '' && this.v != null; + }, + filePlaceholder(): string | null { + if (this.type != 'file') return null; + if (this.v == null) return null; + + if (typeof this.v == 'string') return this.v; + + if (Array.isArray(this.v)) { + return this.v.map(file => file.name).join(', '); + } else { + return this.v.name; + } + } + }, + watch: { + value(v) { + this.v = v; + }, + v(v) { + if (this.type === 'number') { + this.$emit('input', parseInt(v, 10)); + } else { + this.$emit('input', v); + } + + this.invalid = this.$refs.input.validity.badInput; + } + }, + mounted() { + if (this.autofocus) { + this.$nextTick(() => { + this.$refs.input.focus(); + }); + } + + this.$nextTick(() => { + // このコンポーネントが作成された時、非表示状態である場合がある + // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する + const clock = setInterval(() => { + if (this.$refs.prefix) { + this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px'; + if (this.$refs.prefix.offsetWidth) { + this.$refs.input.style.paddingLeft = this.$refs.prefix.offsetWidth + 'px'; + } + } + if (this.$refs.suffix) { + if (this.$refs.suffix.offsetWidth) { + this.$refs.input.style.paddingRight = this.$refs.suffix.offsetWidth + 'px'; + } + } + }, 100); + + this.$once('hook:beforeDestroy', () => { + clearInterval(clock); + }); + }); + + this.$on('keydown', (e: KeyboardEvent) => { + if (e.code == 'Enter') { + this.$emit('enter'); + } + }); + }, + methods: { + focus() { + this.$refs.input.focus(); + }, + togglePassword() { + if (this.type == 'password') { + this.type = 'text' + } else { + this.type = 'password' + } + }, + chooseFile() { + this.$refs.file.click(); + }, + onChangeFile() { + this.v = Array.from((this.$refs.file as any).files); + this.$emit('input', this.v); + this.$emit('change', this.v); + }, + onInput(ev) { + this.changed = true; + this.$emit('change', ev); + } + } +}); +</script> + +<style lang="scss" scoped> +.juejbjww { + position: relative; + margin: 32px 0; + + > .icon { + position: absolute; + top: 0; + left: 0; + width: 24px; + text-align: center; + line-height: 32px; + + &:not(:empty) + .input { + margin-left: 28px; + } + } + + > .input { + position: relative; + + &:before { + content: ''; + display: block; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: var(--inputBorder); + } + + &:after { + content: ''; + display: block; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background: var(--accent); + opacity: 0; + transform: scaleX(0.12); + transition: border 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + will-change: border opacity transform; + } + + > .label { + position: absolute; + z-index: 1; + top: 0; + left: 0; + pointer-events: none; + transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); + transition-duration: 0.3s; + font-size: 16px; + line-height: 32px; + color: var(--inputLabel); + pointer-events: none; + //will-change transform + transform-origin: top left; + transform: scale(1); + } + + > .title { + position: absolute; + z-index: 1; + top: -17px; + left: 0 !important; + pointer-events: none; + font-size: 16px; + line-height: 32px; + color: var(--inputLabel); + pointer-events: none; + //will-change transform + transform-origin: top left; + transform: scale(.75); + white-space: nowrap; + width: 133%; + overflow: hidden; + text-overflow: ellipsis; + + > .warning { + margin-left: 0.5em; + color: var(--infoWarnFg); + + > svg { + margin-right: 0.1em; + } + } + } + + > input { + display: block; + width: 100%; + margin: 0; + padding: 0; + font: inherit; + font-weight: normal; + font-size: 16px; + line-height: 32px; + color: var(--inputText); + background: transparent; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + box-sizing: border-box; + + &[type='file'] { + display: none; + } + } + + > .prefix, + > .suffix { + display: block; + position: absolute; + z-index: 1; + top: 0; + font-size: 16px; + line-height: 32px; + color: var(--inputLabel); + pointer-events: none; + + &:empty { + display: none; + } + + > * { + display: inline-block; + min-width: 16px; + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + > .prefix { + left: 0; + padding-right: 4px; + } + + > .suffix { + right: 0; + padding-left: 4px; + } + } + + > .save { + margin: 6px 0 0 0; + font-size: 13px; + } + + > .desc { + margin: 6px 0 0 0; + font-size: 13px; + opacity: 0.7; + + &:empty { + display: none; + } + + * { + margin: 0; + } + } + + &.focused { + > .input { + &:after { + opacity: 1; + transform: scaleX(1); + } + + > .label { + color: var(--accent); + } + } + } + + &.focused, + &.filled { + > .input { + > .label { + top: -17px; + left: 0 !important; + transform: scale(0.75); + } + } + } + + &.inline { + display: inline-block; + margin: 0; + } + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } +} +</style> diff --git a/src/client/components/ui/pagination.vue b/src/client/components/ui/pagination.vue new file mode 100644 index 0000000000..d953824e00 --- /dev/null +++ b/src/client/components/ui/pagination.vue @@ -0,0 +1,59 @@ +<template> +<sequential-entrance class="cxiknjgy" :class="{ autoMargin }"> + <slot :items="items"></slot> + <div class="empty" v-if="empty" key="_empty_"> + <slot name="empty"></slot> + </div> + <div class="more" v-if="more" key="_more_"> + <mk-button :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore()"> + <template v-if="!moreFetching">{{ $t('loadMore') }}</template> + <template v-if="moreFetching"><fa :icon="faSpinner" pulse fixed-width/></template> + </mk-button> + </div> +</sequential-entrance> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import MkButton from './button.vue'; +import paging from '../../scripts/paging'; + +export default Vue.extend({ + mixins: [ + paging({}), + ], + + components: { + MkButton + }, + + props: { + pagination: { + required: true + }, + autoMargin: { + required: false, + default: true + } + }, + + data() { + return { + faSpinner + }; + }, +}); +</script> + +<style lang="scss" scoped> +.cxiknjgy { + &.autoMargin > *:not(:last-child) { + margin-bottom: 16px; + + @media (max-width: 500px) { + margin-bottom: 8px; + } + } +} +</style> diff --git a/src/client/components/ui/radio.vue b/src/client/components/ui/radio.vue new file mode 100644 index 0000000000..7659d147e6 --- /dev/null +++ b/src/client/components/ui/radio.vue @@ -0,0 +1,119 @@ +<template> +<div + class="novjtctn" + :class="{ disabled, checked }" + :aria-checked="checked" + :aria-disabled="disabled" + @click="toggle" +> + <input type="radio" + :disabled="disabled" + > + <span class="button"> + <span></span> + </span> + <span class="label"><slot></slot></span> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + model: { + prop: 'model', + event: 'change' + }, + props: { + model: { + required: false + }, + value: { + required: false + }, + disabled: { + type: Boolean, + default: false + } + }, + computed: { + checked(): boolean { + return this.model === this.value; + } + }, + methods: { + toggle() { + this.$emit('change', this.value); + } + } +}); +</script> + +<style lang="scss" scoped> +.novjtctn { + display: inline-block; + margin: 0 32px 0 0; + cursor: pointer; + transition: all 0.3s; + + > * { + user-select: none; + } + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.checked { + > .button { + border-color: var(--radioActive); + + &:after { + background-color: var(--radioActive); + transform: scale(1); + opacity: 1; + } + } + } + + > input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + > .button { + position: absolute; + width: 20px; + height: 20px; + background: none; + border: solid 2px var(--inputLabel); + border-radius: 100%; + transition: inherit; + + &:after { + content: ''; + display: block; + position: absolute; + top: 3px; + right: 3px; + bottom: 3px; + left: 3px; + border-radius: 100%; + opacity: 0; + transform: scale(0); + transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); + } + } + + > .label { + margin-left: 28px; + display: block; + font-size: 16px; + line-height: 20px; + cursor: pointer; + } +} +</style> diff --git a/src/client/components/ui/select.vue b/src/client/components/ui/select.vue new file mode 100644 index 0000000000..8bad7c5d65 --- /dev/null +++ b/src/client/components/ui/select.vue @@ -0,0 +1,220 @@ +<template> +<div class="eiipwacr" :class="{ focused, disabled, filled, inline }"> + <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" + v-model="v" + :required="required" + :disabled="disabled" + @focus="focused = true" + @blur="focused = false" + > + <slot></slot> + </select> + <div class="suffix"><slot name="suffix"></slot></div> + </div> + <div class="text"><slot name="text"></slot></div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: false + }, + required: { + type: Boolean, + required: false + }, + disabled: { + type: Boolean, + required: false + }, + inline: { + type: Boolean, + required: false, + default: false + }, + }, + data() { + return { + focused: false + }; + }, + computed: { + v: { + get() { + return this.value; + }, + set(v) { + this.$emit('input', v); + } + }, + filled(): boolean { + return this.v != '' && this.v != null; + } + }, + mounted() { + if (this.$refs.prefix) { + this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px'; + } + }, + methods: { + focus() { + this.$refs.input.focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.eiipwacr { + position: relative; + margin: 32px 0; + + > .icon { + position: absolute; + top: 0; + left: 0; + width: 24px; + text-align: center; + line-height: 32px; + + &:not(:empty) + .input { + margin-left: 28px; + } + } + + > .input { + display: flex; + + &:before { + content: ''; + display: block; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: var(--inputBorder); + } + + &:after { + content: ''; + display: block; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background: var(--accent); + opacity: 0; + transform: scaleX(0.12); + transition: border 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + will-change: border opacity transform; + } + + > .label { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); + transition-duration: 0.3s; + font-size: 16px; + line-height: 32px; + pointer-events: none; + //will-change transform + transform-origin: top left; + transform: scale(1); + } + + > select { + display: block; + flex: 1; + width: 100%; + padding: 0; + font: inherit; + font-weight: normal; + font-size: 16px; + height: 32px; + background: var(--panel); + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + color: var(--fg); + } + + > .prefix, + > .suffix { + display: block; + align-self: center; + justify-self: center; + font-size: 16px; + line-height: 32px; + color: rgba(#000, 0.54); + pointer-events: none; + + &:empty { + display: none; + } + + > * { + display: block; + min-width: 16px; + } + } + + > .prefix { + padding-right: 4px; + } + + > .suffix { + padding-left: 4px; + } + } + + > .text { + margin: 6px 0; + font-size: 13px; + + &:empty { + display: none; + } + + * { + margin: 0; + } + } + + &.focused { + > .input { + &:after { + opacity: 1; + transform: scaleX(1); + } + + > .label { + color: var(--accent); + } + } + } + + &.focused, + &.filled { + > .input { + > .label { + top: -17px; + left: 0 !important; + transform: scale(0.75); + } + } + } +} +</style> diff --git a/src/client/components/ui/switch.vue b/src/client/components/ui/switch.vue new file mode 100644 index 0000000000..d4680ca2ef --- /dev/null +++ b/src/client/components/ui/switch.vue @@ -0,0 +1,150 @@ +<template> +<div + class="ziffeoms" + :class="{ disabled, checked }" + role="switch" + :aria-checked="checked" + :aria-disabled="disabled" + @click="toggle" +> + <input + type="checkbox" + ref="input" + :disabled="disabled" + @keydown.enter="toggle" + > + <span class="button"> + <span></span> + </span> + <span class="label"> + <span :aria-hidden="!checked"><slot></slot></span> + <p :aria-hidden="!checked"> + <slot name="desc"></slot> + </p> + </span> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + model: { + prop: 'value', + event: 'change' + }, + props: { + value: { + type: Boolean, + default: false + }, + disabled: { + type: Boolean, + default: false + } + }, + computed: { + checked(): boolean { + return this.value; + } + }, + methods: { + toggle() { + if (this.disabled) return; + this.$emit('change', !this.checked); + } + } +}); +</script> + +<style lang="scss" scoped> +.ziffeoms { + position: relative; + display: flex; + margin: 32px 0; + cursor: pointer; + transition: all 0.3s; + + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + + > * { + user-select: none; + } + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.checked { + > .button { + background-color: var(--xxubwiul); + border-color: var(--xxubwiul); + + > * { + background-color: var(--accent); + transform: translateX(14px); + } + } + } + + > input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + > .button { + position: relative; + display: inline-block; + flex-shrink: 0; + margin: 3px 0 0 0; + width: 34px; + height: 14px; + background: var(--nhzhphzx); + outline: none; + border-radius: 14px; + transition: inherit; + + > * { + position: absolute; + top: -3px; + left: 0; + border-radius: 100%; + transition: background-color 0.3s, transform 0.3s; + width: 20px; + height: 20px; + background-color: #fff; + box-shadow: 0 2px 1px -1px rgba(#000, 0.2), 0 1px 1px 0 rgba(#000, 0.14), 0 1px 3px 0 rgba(#000, 0.12); + } + } + + > .label { + margin-left: 8px; + display: block; + font-size: 16px; + cursor: pointer; + transition: inherit; + color: var(--fg); + + > span { + display: block; + line-height: 20px; + transition: inherit; + } + + > p { + margin: 0; + opacity: 0.7; + font-size: 90%; + } + } +} +</style> diff --git a/src/client/components/ui/textarea.vue b/src/client/components/ui/textarea.vue new file mode 100644 index 0000000000..7b42b78a73 --- /dev/null +++ b/src/client/components/ui/textarea.vue @@ -0,0 +1,218 @@ +<template> +<div class="adhpbeos" :class="{ focused, filled, tall, pre }"> + <div class="input"> + <span class="label" ref="label"><slot></slot></span> + <textarea ref="input" + :value="value" + :required="required" + :readonly="readonly" + :pattern="pattern" + :autocomplete="autocomplete" + @input="onInput" + @focus="focused = true" + @blur="focused = false" + ></textarea> + </div> + <button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $t('save') }}</button> + <div class="desc"><slot name="desc"></slot></div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: false + }, + required: { + type: Boolean, + required: false + }, + readonly: { + type: Boolean, + required: false + }, + pattern: { + type: String, + required: false + }, + autocomplete: { + type: String, + required: false + }, + tall: { + type: Boolean, + required: false, + default: false + }, + pre: { + type: Boolean, + required: false, + default: false + }, + save: { + type: Function, + required: false, + }, + }, + data() { + return { + focused: false, + changed: false, + } + }, + computed: { + filled(): boolean { + return this.value != '' && this.value != null; + } + }, + methods: { + focus() { + this.$refs.input.focus(); + }, + onInput(ev) { + this.changed = true; + this.$emit('input', ev.target.value); + } + } +}); +</script> + +<style lang="scss" scoped> +.adhpbeos { + margin: 42px 0 32px 0; + position: relative; + + &:last-child { + margin-bottom: 0; + } + + > .input { + position: relative; + + &:before { + content: ''; + display: block; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: none; + border: solid 1px var(--inputBorder); + border-radius: 3px; + pointer-events: none; + } + + &:after { + content: ''; + display: block; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: none; + border: solid 2px var(--accent); + border-radius: 3px; + opacity: 0; + transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; + } + + > .label { + position: absolute; + top: 6px; + left: 12px; + pointer-events: none; + transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); + transition-duration: 0.3s; + font-size: 16px; + line-height: 32px; + pointer-events: none; + //will-change transform + transform-origin: top left; + transform: scale(1); + } + + > textarea { + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 130px; + padding: 12px; + box-sizing: border-box; + font: inherit; + font-weight: normal; + font-size: 16px; + background: transparent; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + color: var(--fg); + } + } + + > .save { + margin: 6px 0 0 0; + font-size: 13px; + } + + > .desc { + margin: 6px 0 0 0; + font-size: 13px; + opacity: 0.7; + + &:empty { + display: none; + } + + * { + margin: 0; + } + } + + &.focused { + > .input { + &:after { + opacity: 1; + } + + > .label { + color: var(--accent); + } + } + } + + &.focused, + &.filled { + > .input { + > .label { + top: -24px; + left: 0 !important; + transform: scale(0.75); + } + } + } + + &.tall { + > .input { + > textarea { + min-height: 200px; + } + } + } + + &.pre { + > .input { + > textarea { + white-space: pre; + } + } + } +} +</style> diff --git a/src/client/components/uploader.vue b/src/client/components/uploader.vue new file mode 100644 index 0000000000..14a4f845c1 --- /dev/null +++ b/src/client/components/uploader.vue @@ -0,0 +1,242 @@ +<template> +<div class="mk-uploader"> + <ol v-if="uploads.length > 0"> + <li v-for="ctx in uploads" :key="ctx.id"> + <div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div> + <div class="top"> + <p class="name"><fa icon="spinner" pulse/>{{ ctx.name }}</p> + <p class="status"> + <span class="initing" v-if="ctx.progress == undefined">{{ $t('waiting') }}<mk-ellipsis/></span> + <span class="kb" v-if="ctx.progress != undefined">{{ String(Math.floor(ctx.progress.value / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progress.max / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span> + <span class="percentage" v-if="ctx.progress != undefined">{{ Math.floor((ctx.progress.value / ctx.progress.max) * 100) }}</span> + </p> + </div> + <progress v-if="ctx.progress != undefined && ctx.progress.value != ctx.progress.max" :value="ctx.progress.value" :max="ctx.progress.max"></progress> + <div class="progress initing" v-if="ctx.progress == undefined"></div> + <div class="progress waiting" v-if="ctx.progress != undefined && ctx.progress.value == ctx.progress.max"></div> + </li> + </ol> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import { apiUrl } from '../config'; +//import getMD5 from '../../scripts/get-md5'; + +export default Vue.extend({ + i18n, + data() { + return { + uploads: [] + }; + }, + methods: { + checkExistence(fileData: ArrayBuffer): Promise<any> { + return new Promise((resolve, reject) => { + const data = new FormData(); + data.append('md5', getMD5(fileData)); + + this.$root.api('drive/files/find-by-hash', { + md5: getMD5(fileData) + }).then(resp => { + resolve(resp.length > 0 ? resp[0] : null); + }); + }); + }, + + upload(file: File, folder: any, name?: string) { + if (folder && typeof folder == 'object') folder = folder.id; + + const id = Math.random(); + + const reader = new FileReader(); + reader.onload = (e: any) => { + const ctx = { + id: id, + name: name || file.name || 'untitled', + progress: undefined, + img: window.URL.createObjectURL(file) + }; + + this.uploads.push(ctx); + this.$emit('change', this.uploads); + + const data = new FormData(); + data.append('i', this.$store.state.i.token); + data.append('force', 'true'); + data.append('file', file); + + if (folder) data.append('folderId', folder); + if (name) data.append('name', name); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = (e: any) => { + const driveFile = JSON.parse(e.target.response); + + this.$emit('uploaded', driveFile); + + this.uploads = this.uploads.filter(x => x.id != id); + this.$emit('change', this.uploads); + }; + + xhr.upload.onprogress = e => { + if (e.lengthComputable) { + if (ctx.progress == undefined) ctx.progress = {}; + ctx.progress.max = e.total; + ctx.progress.value = e.loaded; + } + }; + + xhr.send(data); + } + reader.readAsArrayBuffer(file); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-uploader { + overflow: auto; +} +.mk-uploader:empty { + display: none; +} +.mk-uploader > ol { + display: block; + margin: 0; + padding: 0; + list-style: none; +} +.mk-uploader > ol > li { + display: grid; + margin: 8px 0 0 0; + padding: 0; + height: 36px; + width: 100%; + box-shadow: 0 -1px 0 var(--accentAlpha01); + border-top: solid 8px transparent; + grid-template-columns: 36px calc(100% - 44px); + grid-template-rows: 1fr 8px; + column-gap: 8px; + box-sizing: content-box; +} +.mk-uploader > ol > li:first-child { + margin: 0; + box-shadow: none; + border-top: none; +} +.mk-uploader > ol > li > .img { + display: block; + background-size: cover; + background-position: center center; + grid-column: 1/2; + grid-row: 1/3; +} +.mk-uploader > ol > li > .top { + display: flex; + grid-column: 2/3; + grid-row: 1/2; +} +.mk-uploader > ol > li > .top > .name { + display: block; + padding: 0 8px 0 0; + margin: 0; + font-size: 0.8em; + color: var(--accentAlpha07); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + flex-shrink: 1; +} +.mk-uploader > ol > li > .top > .name > [data-icon] { + margin-right: 4px; +} +.mk-uploader > ol > li > .top > .status { + display: block; + margin: 0 0 0 auto; + padding: 0; + font-size: 0.8em; + flex-shrink: 0; +} +.mk-uploader > ol > li > .top > .status > .initing { + color: var(--accentAlpha05); +} +.mk-uploader > ol > li > .top > .status > .kb { + color: var(--accentAlpha05); +} +.mk-uploader > ol > li > .top > .status > .percentage { + display: inline-block; + width: 48px; + text-align: right; + color: var(--accentAlpha07); +} +.mk-uploader > ol > li > .top > .status > .percentage:after { + content: '%'; +} +.mk-uploader > ol > li > progress { + display: block; + background: transparent; + border: none; + border-radius: 4px; + overflow: hidden; + grid-column: 2/3; + grid-row: 2/3; + z-index: 2; +} +.mk-uploader > ol > li > progress::-webkit-progress-value { + background: var(--accent); +} +.mk-uploader > ol > li > progress::-webkit-progress-bar { + background: var(--accentAlpha01); +} +.mk-uploader > ol > li > .progress { + display: block; + border: none; + border-radius: 4px; + background: linear-gradient(45deg, var(--accentLighten30) 25%, var(--accent) 25%, var(--accent) 50%, var(--accentLighten30) 50%, var(--accentLighten30) 75%, var(--accent) 75%, var(--accent)); + background-size: 32px 32px; + animation: bg 1.5s linear infinite; + grid-column: 2/3; + grid-row: 2/3; + z-index: 1; +} +.mk-uploader > ol > li > .progress.initing { + opacity: 0.3; +} +@-moz-keyframes bg { + from { + background-position: 0 0; + } + to { + background-position: -64px 32px; + } +} +@-webkit-keyframes bg { + from { + background-position: 0 0; + } + to { + background-position: -64px 32px; + } +} +@-o-keyframes bg { + from { + background-position: 0 0; + } + to { + background-position: -64px 32px; + } +} +@keyframes bg { + from { + background-position: 0 0; + } + to { + background-position: -64px 32px; + } +} +</style> diff --git a/src/client/components/url-preview.vue b/src/client/components/url-preview.vue new file mode 100644 index 0000000000..f2ef1f1ba3 --- /dev/null +++ b/src/client/components/url-preview.vue @@ -0,0 +1,331 @@ +<template> +<div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`"> + <button class="disablePlayer" @click="playerEnabled = false" :title="$t('disable-player')"><fa icon="times"/></button> + <iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen /> +</div> +<div v-else-if="tweetUrl && detail" class="twitter"> + <blockquote ref="tweet" class="twitter-tweet" :data-theme="$store.state.device.darkmode ? 'dark' : null"> + <a :href="url"></a> + </blockquote> +</div> +<div v-else class="mk-url-preview" v-size="[{ max: 400 }, { max: 350 }]"> + <transition name="zoom" mode="out-in"> + <component :is="hasRoute ? 'router-link' : 'a'" :class="{ compact }" :[attr]="hasRoute ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url" v-if="!fetching"> + <div class="thumbnail" v-if="thumbnail" :style="`background-image: url('${thumbnail}')`"> + <button class="_button" v-if="!playerEnabled && player.url" @click.prevent="playerEnabled = true" :title="$t('enable-player')"><fa :icon="faPlayCircle"/></button> + </div> + <article> + <header> + <h1 :title="title">{{ title }}</h1> + </header> + <p v-if="description" :title="description">{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p> + <footer> + <img class="icon" v-if="icon" :src="icon"/> + <p :title="sitename">{{ sitename }}</p> + </footer> + </article> + </component> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faPlayCircle } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../i18n'; +import { url as local, lang } from '../config'; + +export default Vue.extend({ + i18n, + + props: { + url: { + type: String, + require: true + }, + + detail: { + type: Boolean, + required: false, + default: false + }, + + compact: { + type: Boolean, + required: false, + default: false + }, + }, + + data() { + const isSelf = this.url.startsWith(local); + const hasRoute = + (this.url.substr(local.length) === '/') || + this.url.substr(local.length).startsWith('/@') || + this.url.substr(local.length).startsWith('/notes/') || + this.url.substr(local.length).startsWith('/tags/'); + return { + local, + fetching: true, + title: null, + description: null, + thumbnail: null, + icon: null, + sitename: null, + player: { + url: null, + width: null, + height: null + }, + tweetUrl: null, + playerEnabled: false, + self: isSelf, + hasRoute: hasRoute, + attr: hasRoute ? 'to' : 'href', + target: hasRoute ? null : '_blank', + faPlayCircle + }; + }, + + created() { + const requestUrl = new URL(this.url); + + if (this.detail && requestUrl.hostname == 'twitter.com' && /^\/.+\/status(es)?\/\d+/.test(requestUrl.pathname)) { + this.tweetUrl = requestUrl; + const twttr = (window as any).twttr || {}; + const loadTweet = () => twttr.widgets.load(this.$refs.tweet); + + if (twttr.widgets) { + Vue.nextTick(loadTweet); + } else { + const wjsId = 'twitter-wjs'; + if (!document.getElementById(wjsId)) { + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('id', wjsId); + script.setAttribute('src', 'https://platform.twitter.com/widgets.js'); + head.appendChild(script); + } + twttr.ready = loadTweet; + (window as any).twttr = twttr; + } + return; + } + + if (requestUrl.hostname === 'music.youtube.com') { + requestUrl.hostname = 'youtube.com'; + } + + const requestLang = (lang || 'ja-JP').replace('ja-KS', 'ja-JP'); + + requestUrl.hash = ''; + + fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => { + res.json().then(info => { + if (info.url == null) return; + this.title = info.title; + this.description = info.description; + this.thumbnail = info.thumbnail; + this.icon = info.icon; + this.sitename = info.sitename; + this.fetching = false; + this.player = info.player; + }) + }); + } +}); +</script> + +<style lang="scss" scoped> +.player { + position: relative; + width: 100%; + + > button { + position: absolute; + top: -1.5em; + right: 0; + font-size: 1em; + width: 1.5em; + height: 1.5em; + padding: 0; + margin: 0; + color: var(--fg); + background: rgba(128, 128, 128, 0.2); + opacity: 0.7; + + &:hover { + opacity: 0.9; + } + } + + > iframe { + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + } +} + +.mk-url-preview { + &.max-width_400px { + > a { + font-size: 12px; + + > .thumbnail { + height: 80px; + } + + > article { + padding: 12px; + } + } + } + + &.max-width_350px { + > a { + font-size: 10px; + + > .thumbnail { + height: 70px; + } + + > article { + padding: 8px; + + > header { + margin-bottom: 4px; + } + + > footer { + margin-top: 4px; + + > img { + width: 12px; + height: 12px; + } + } + } + + &.compact { + > .thumbnail { + position: absolute; + width: 56px; + height: 100%; + } + + > article { + left: 56px; + width: calc(100% - 56px); + padding: 4px; + + > header { + margin-bottom: 2px; + } + + > footer { + margin-top: 2px; + } + } + } + } + } + + > a { + position: relative; + display: block; + font-size: 14px; + box-shadow: 0 1px 4px var(--tyvedwbe); + border-radius: 4px; + overflow: hidden; + + &:hover { + text-decoration: none; + border-color: rgba(0, 0, 0, 0.2); + + > article > header > h1 { + text-decoration: underline; + } + } + + > .thumbnail { + position: absolute; + width: 100px; + height: 100%; + background-position: center; + background-size: cover; + display: flex; + justify-content: center; + align-items: center; + + > button { + font-size: 3.5em; + opacity: 0.7; + + &:hover { + font-size: 4em; + opacity: 0.9; + } + } + + & + article { + left: 100px; + width: calc(100% - 100px); + } + } + + > article { + position: relative; + box-sizing: border-box; + padding: 16px; + + > header { + margin-bottom: 8px; + + > h1 { + margin: 0; + font-size: 1em; + } + } + + > p { + margin: 0; + font-size: 0.8em; + } + + > footer { + margin-top: 8px; + height: 16px; + + > img { + display: inline-block; + width: 16px; + height: 16px; + margin-right: 4px; + vertical-align: top; + } + + > p { + display: inline-block; + margin: 0; + color: var(--urlPreviewInfo); + font-size: 0.8em; + line-height: 16px; + vertical-align: top; + } + } + } + + &.compact { + > article { + > header h1, p, footer { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + } + } +} +</style> diff --git a/src/client/app/common/views/components/url.vue b/src/client/components/url.vue index 3a304ad6e7..082e744001 100644 --- a/src/client/app/common/views/components/url.vue +++ b/src/client/components/url.vue @@ -11,14 +11,15 @@ <span class="pathname" v-if="pathname != ''">{{ self ? pathname.substr(1) : pathname }}</span> <span class="query">{{ query }}</span> <span class="hash">{{ hash }}</span> - <fa icon="external-link-square-alt" v-if="target === '_blank'"/> + <fa :icon="faExternalLinkSquareAlt" v-if="target === '_blank'" class="icon"/> </component> </template> <script lang="ts"> import Vue from 'vue'; +import { faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons'; import { toUnicode as decodePunycode } from 'punycode'; -import { url as local } from '../../../config'; +import { url as local } from '../config'; export default Vue.extend({ props: ['url', 'rel'], @@ -28,8 +29,7 @@ export default Vue.extend({ (this.url.substr(local.length) === '/') || this.url.substr(local.length).startsWith('/@') || this.url.substr(local.length).startsWith('/notes/') || - this.url.substr(local.length).startsWith('/tags/') || - this.url.substr(local.length).startsWith('/pages/')); + this.url.substr(local.length).startsWith('/tags/')); return { local, schema: null, @@ -41,7 +41,8 @@ export default Vue.extend({ self: isSelf, hasRoute: hasRoute, attr: hasRoute ? 'to' : 'href', - target: hasRoute ? null : '_blank' + target: hasRoute ? null : '_blank', + faExternalLinkSquareAlt }; }, created() { @@ -56,31 +57,39 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.mk-url - word-break break-all +<style lang="scss" scoped> +.mk-url { + word-break: break-all; - > [data-icon] - padding-left 2px - font-size .9em - font-weight 400 - font-style normal + > .icon { + padding-left: 2px; + font-size: .9em; + font-weight: 400; + font-style: normal; + } - > .self - font-weight bold + > .self { + font-weight: bold; + } - > .schema - opacity 0.5 + > .schema { + opacity: 0.5; + } - > .hostname - font-weight bold + > .hostname { + font-weight: bold; + } - > .pathname - opacity 0.8 + > .pathname { + opacity: 0.8; + } - > .query - opacity 0.5 + > .query { + opacity: 0.5; + } - > .hash - font-style italic + > .hash { + font-style: italic; + } +} </style> diff --git a/src/client/components/user-list.vue b/src/client/components/user-list.vue new file mode 100644 index 0000000000..14a96f3c6f --- /dev/null +++ b/src/client/components/user-list.vue @@ -0,0 +1,148 @@ +<template> +<mk-container :body-togglable="true" :expanded="expanded"> + <template #header><slot></slot></template> + + <mk-error v-if="error" @retry="init()"/> + + <div class="efvhhmdq"> + <div class="no-users" v-if="empty"> + <p>{{ $t('no-users') }}</p> + </div> + <div class="user" v-for="user in users" :key="user.id"> + <mk-avatar class="avatar" :user="user"/> + <div class="body"> + <div class="name"> + <router-link class="name" :to="user | userPage" v-user-preview="user.id"><mk-user-name :user="user"/></router-link> + <span class="username"><mk-acct :user="user"/></span> + </div> + <div class="description"> + <mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> + <span v-else class="empty">{{ $t('noAccountDescription') }}</span> + </div> + </div> + <x-follow-button class="koudoku-button" v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" mini/> + </div> + <button class="more" :class="{ fetching: moreFetching }" v-if="more" @click="fetchMore()" :disabled="moreFetching"> + <template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template>{{ moreFetching ? $t('@.loading') : $t('@.load-more') }} + </button> + </div> +</mk-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import paging from '../scripts/paging'; +import MkContainer from './ui/container.vue'; +import XFollowButton from './follow-button.vue'; + +export default Vue.extend({ + i18n, + + components: { + MkContainer, + XFollowButton, + }, + + mixins: [ + paging({}), + ], + + props: { + pagination: { + required: true + }, + extract: { + required: false + }, + expanded: { + type: Boolean, + default: true + }, + }, + + computed: { + users() { + return this.extract ? this.extract(this.items) : this.items; + } + } +}); +</script> + +<style lang="scss" scoped> +.efvhhmdq { + > .no-users { + text-align: center; + } + + > .user { + position: relative; + display: flex; + padding: 16px; + border-bottom: solid 1px var(--divider); + + &:last-child { + border-bottom: none; + } + + > .avatar { + display: block; + flex-shrink: 0; + margin: 0 12px 0 0; + width: 42px; + height: 42px; + border-radius: 8px; + } + + > .body { + flex: 1; + + > .name { + font-weight: bold; + + > .name { + margin-right: 8px; + } + + > .username { + opacity: 0.7; + } + } + + > .description { + font-size: 90%; + + > .empty { + opacity: 0.7; + } + } + } + + > .koudoku-button { + flex-shrink: 0; + } + } + + > .more { + display: block; + width: 100%; + padding: 16px; + + &:hover { + background: rgba(#000, 0.025); + } + + &:active { + background: rgba(#000, 0.05); + } + + &.fetching { + cursor: wait; + } + + > [data-icon] { + margin-right: 4px; + } + } +} +</style> diff --git a/src/client/components/user-menu.vue b/src/client/components/user-menu.vue new file mode 100644 index 0000000000..6e3280031c --- /dev/null +++ b/src/client/components/user-menu.vue @@ -0,0 +1,188 @@ +<template> +<x-menu :source="source" :items="items" @closed="$emit('closed')"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments } from '@fortawesome/free-solid-svg-icons'; +import { faSnowflake, faEnvelope } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../i18n'; +import XMenu from './menu.vue'; +import copyToClipboard from '../scripts/copy-to-clipboard'; +import { host } from '../config'; +import getAcct from '../../misc/acct/render'; + +export default Vue.extend({ + i18n, + + components: { + XMenu + }, + + props: ['user', 'source'], + + data() { + let menu = [{ + icon: faAt, + text: this.$t('copyUsername'), + action: () => { + copyToClipboard(`@${this.user.username}@${this.user.host || host}`); + } + }, { + icon: faEnvelope, + text: this.$t('sendMessage'), + action: () => { + this.$root.post({ specified: this.user }); + } + }, this.$store.state.i.id != this.user.id ? { + type: 'link', + to: `/my/messaging/${getAcct(this.user)}`, + icon: faComments, + text: this.$t('startMessaging'), + } : undefined, null, { + icon: faListUl, + text: this.$t('addToList'), + action: this.pushList + }] as any; + + if (this.$store.getters.isSignedIn && this.$store.state.i.id != this.user.id) { + menu = menu.concat([null, { + icon: this.user.isMuted ? faEye : faEyeSlash, + text: this.user.isMuted ? this.$t('unmute') : this.$t('mute'), + action: this.toggleMute + }, { + icon: faBan, + text: this.user.isBlocking ? this.$t('unblock') : this.$t('block'), + action: this.toggleBlock + }]); + + if (this.$store.state.i.isAdmin) { + menu = menu.concat([null, { + icon: faSnowflake, + text: this.user.isSuspended ? this.$t('unsuspend') : this.$t('suspend'), + action: this.toggleSuspend + }]); + } + } + + if (this.$store.getters.isSignedIn && this.$store.state.i.id === this.user.id) { + menu = menu.concat([null, { + icon: faPencilAlt, + text: this.$t('editProfile'), + action: () => { + this.$router.push('/my/settings'); + } + }]); + } + + return { + items: menu + }; + }, + + methods: { + async pushList() { + const t = this.$t('selectList'); // なぜか後で参照すると null になるので最初にメモリに確保しておく + const lists = await this.$root.api('users/lists/list'); + if (lists.length === 0) { + this.$root.dialog({ + type: 'error', + text: this.$t('youHaveNoLists') + }); + return; + } + const { canceled, result: listId } = await this.$root.dialog({ + type: null, + title: t, + select: { + items: lists.map(list => ({ + value: list.id, text: list.name + })) + }, + showCancelButton: true + }); + if (canceled) return; + this.$root.api('users/lists/push', { + listId: listId, + userId: this.user.id + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + + async toggleMute() { + this.$root.api(this.user.isMuted ? 'mute/delete' : 'mute/create', { + userId: this.user.id + }).then(() => { + this.user.isMuted = !this.user.isMuted; + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }, e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + + async toggleBlock() { + if (!await this.getConfirmed(this.user.isBlocking ? this.$t('unblockConfirm') : this.$t('blockConfirm'))) return; + + this.$root.api(this.user.isBlocking ? 'blocking/delete' : 'blocking/create', { + userId: this.user.id + }).then(() => { + this.user.isBlocking = !this.user.isBlocking; + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }, e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + + async toggleSuspend() { + if (!await this.getConfirmed(this.$t(this.user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return; + + this.$root.api(this.user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', { + userId: this.user.id + }).then(() => { + this.user.isSuspended = !this.user.isSuspended; + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }, e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + + async getConfirmed(text: string): Promise<Boolean> { + const confirm = await this.$root.dialog({ + type: 'warning', + showCancelButton: true, + title: 'confirm', + text, + }); + + return !confirm.canceled; + }, + } +}); +</script> diff --git a/src/client/components/user-moderate-dialog.vue b/src/client/components/user-moderate-dialog.vue new file mode 100644 index 0000000000..894db5384e --- /dev/null +++ b/src/client/components/user-moderate-dialog.vue @@ -0,0 +1,108 @@ +<template> +<x-window @closed="() => { $emit('closed'); destroyDom(); }" :avatar="user"> + <template #header><mk-user-name :user="user"/></template> + <div class="vrcsvlkm"> + <mk-button @click="changePassword()">{{ $t('changePassword') }}</mk-button> + <mk-switch @change="toggleSilence()" v-model="silenced">{{ $t('silence') }}</mk-switch> + <mk-switch @change="toggleSuspend()" v-model="suspended">{{ $t('suspend') }}</mk-switch> + </div> +</x-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import MkButton from './ui/button.vue'; +import MkSwitch from './ui/switch.vue'; +import XWindow from './window.vue'; + +export default Vue.extend({ + i18n, + + components: { + MkButton, + MkSwitch, + XWindow, + }, + + props: { + user: { + type: Object, + required: true + } + }, + + data() { + return { + silenced: this.user.isSilenced, + suspended: this.user.isSuspended, + }; + }, + + methods: { + async changePassword() { + const { canceled: canceled, result: newPassword } = await this.$root.dialog({ + title: this.$t('newPassword'), + input: { + type: 'password' + } + }); + if (canceled) return; + + const dialog = this.$root.dialog({ + type: 'waiting', + iconOnly: true + }); + + this.$root.api('admin/change-password', { + userId: this.user.id, + newPassword + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }).finally(() => { + dialog.close(); + }); + }, + + async toggleSilence() { + const confirm = await this.$root.dialog({ + type: 'warning', + showCancelButton: true, + text: this.silenced ? this.$t('silenceConfirm') : this.$t('unsilenceConfirm'), + }); + if (confirm.canceled) { + this.silenced = !this.silenced; + } else { + this.$root.api(this.silenced ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id }); + } + }, + + async toggleSuspend() { + const confirm = await this.$root.dialog({ + type: 'warning', + showCancelButton: true, + text: this.suspended ? this.$t('suspendConfirm') : this.$t('unsuspendConfirm'), + }); + if (confirm.canceled) { + this.suspended = !this.suspended; + } else { + this.$root.api(this.silenced ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id }); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.vrcsvlkm { + +} +</style> diff --git a/src/client/app/common/views/components/user-name.vue b/src/client/components/user-name.vue index 425cb587c4..425cb587c4 100644 --- a/src/client/app/common/views/components/user-name.vue +++ b/src/client/components/user-name.vue diff --git a/src/client/components/user-preview.vue b/src/client/components/user-preview.vue new file mode 100644 index 0000000000..f20335d02b --- /dev/null +++ b/src/client/components/user-preview.vue @@ -0,0 +1,181 @@ +<template> +<transition name="popup" appear @after-leave="() => { $emit('closed'); destroyDom(); }"> + <div v-if="show" class="fxxzrfni _panel" ref="content" :style="{ top: top + 'px', left: left + 'px' }" @mouseover="() => { $emit('mouseover'); }" @mouseleave="() => { $emit('mouseleave'); }"> + <div class="banner" :style="u.bannerUrl ? `background-image: url(${u.bannerUrl})` : ''"></div> + <mk-avatar class="avatar" :user="u" :disable-preview="true"/> + <div class="title"> + <router-link class="name" :to="u | userPage"><mk-user-name :user="u" :nowrap="false"/></router-link> + <p class="username"><mk-acct :user="u"/></p> + </div> + <div class="description"> + <mfm v-if="u.description" :text="u.description" :author="u" :i="$store.state.i" :custom-emojis="u.emojis"/> + </div> + <div class="status"> + <div> + <p>{{ $t('notes') }}</p><span>{{ u.notesCount }}</span> + </div> + <div> + <p>{{ $t('following') }}</p><span>{{ u.followingCount }}</span> + </div> + <div> + <p>{{ $t('followers') }}</p><span>{{ u.followersCount }}</span> + </div> + </div> + <x-follow-button class="koudoku-button" v-if="$store.getters.isSignedIn && u.id != $store.state.i.id" :user="u" mini/> + </div> +</transition> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import parseAcct from '../../misc/acct/parse'; +import XFollowButton from './follow-button.vue'; + +export default Vue.extend({ + i18n, + + components: { + XFollowButton + }, + + props: { + user: { + type: [Object, String], + required: true + }, + source: { + required: true + } + }, + + data() { + return { + u: null, + show: false, + top: 0, + left: 0, + }; + }, + + mounted() { + if (typeof this.user == 'object') { + this.u = this.user; + this.show = true; + } else { + const query = this.user.startsWith('@') ? + parseAcct(this.user.substr(1)) : + { userId: this.user }; + + this.$root.api('users/show', query).then(user => { + this.u = user; + this.show = true; + }); + } + + const rect = this.source.getBoundingClientRect(); + const x = ((rect.left + (this.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset; + const y = rect.top + this.source.offsetHeight + window.pageYOffset; + + this.top = y; + this.left = x; + }, + + methods: { + close() { + this.show = false; + (this.$refs.content as any).style.pointerEvents = 'none'; + } + } +}); +</script> + +<style lang="scss" scoped> +.popup-enter-active, .popup-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.popup-enter, .popup-leave-to { + opacity: 0; + transform: scale(0.9); +} + +.fxxzrfni { + position: absolute; + z-index: 11000; + width: 300px; + overflow: hidden; + + > .banner { + height: 84px; + background-color: rgba(0, 0, 0, 0.1); + background-size: cover; + background-position: center; + } + + > .avatar { + display: block; + position: absolute; + top: 62px; + left: 13px; + z-index: 2; + width: 58px; + height: 58px; + border: solid 3px var(--face); + border-radius: 8px; + } + + > .title { + display: block; + padding: 8px 0 8px 82px; + + > .name { + display: inline-block; + margin: 0; + font-weight: bold; + line-height: 16px; + word-break: break-all; + } + + > .username { + display: block; + margin: 0; + line-height: 16px; + font-size: 0.8em; + color: var(--text); + opacity: 0.7; + } + } + + > .description { + padding: 0 16px; + font-size: 0.8em; + color: var(--text); + } + + > .status { + padding: 8px 16px; + + > div { + display: inline-block; + width: 33%; + + > p { + margin: 0; + font-size: 0.7em; + color: var(--text); + } + + > span { + font-size: 1em; + color: var(--accent); + } + } + } + + > .koudoku-button { + position: absolute; + top: 8px; + right: 8px; + } +} +</style> diff --git a/src/client/components/user-select.vue b/src/client/components/user-select.vue new file mode 100644 index 0000000000..a82626652d --- /dev/null +++ b/src/client/components/user-select.vue @@ -0,0 +1,152 @@ +<template> +<x-window ref="window" @closed="() => { $emit('closed'); destroyDom(); }" :with-ok-button="true" :ok-button-disabled="selected == null" @ok="ok()"> + <template #header>{{ $t('selectUser') }}</template> + <div class="tbhwbxda"> + <div class="inputs"> + <mk-input v-model="username" class="input" @input="search" ref="username"><span>{{ $t('username') }}</span><template #prefix>@</template></mk-input> + <mk-input v-model="host" class="input" @input="search"><span>{{ $t('host') }}</span><template #prefix>@</template></mk-input> + </div> + <div class="users"> + <div class="user" v-for="user in users" :key="user.id" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()"> + <mk-avatar :user="user" class="avatar" :disable-link="true"/> + <div class="body"> + <mk-user-name :user="user" class="name"/> + <mk-acct :user="user" class="acct"/> + </div> + </div> + </div> + </div> +</x-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import { faTimes, faCheck } from '@fortawesome/free-solid-svg-icons'; +import MkInput from './ui/input.vue'; +import XWindow from './window.vue'; + +export default Vue.extend({ + i18n, + + components: { + MkInput, + XWindow, + }, + + props: { + }, + + data() { + return { + username: '', + host: '', + users: [], + selected: null, + faTimes, faCheck + }; + }, + + mounted() { + this.focus(); + + this.$nextTick(() => { + this.focus(); + }); + }, + + methods: { + search() { + if (this.username == '' && this.host == '') { + this.users = []; + return; + } + this.$root.api('users/search-by-username-and-host', { + username: this.username, + host: this.host, + limit: 10, + detail: false + }).then(users => { + this.users = users; + }); + }, + + focus() { + this.$refs.username.focus(); + }, + + close() { + this.$refs.window.close(); + }, + + ok() { + this.$emit('selected', this.selected); + this.close(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.tbhwbxda { + display: flex; + flex-direction: column; + overflow: auto; + height: 100%; + + > .inputs { + margin-top: 16px; + + > .input { + display: inline-block; + width: 50%; + margin: 0; + } + } + + > .users { + flex: 1; + overflow: auto; + + > .user { + display: flex; + align-items: center; + padding: 8px 16px; + font-size: 14px; + + &:hover { + background: var(--bwqtlupy); + } + + &.selected { + background: var(--accent); + color: #fff; + } + + > * { + pointer-events: none; + user-select: none; + } + + > .avatar { + width: 45px; + height: 45px; + } + + > .body { + padding: 0 8px; + min-width: 0; + + > .name { + display: block; + font-weight: bold; + } + + > .acct { + opacity: 0.5; + } + } + } + } +} +</style> diff --git a/src/client/components/users-dialog.vue b/src/client/components/users-dialog.vue new file mode 100644 index 0000000000..19310bc4e1 --- /dev/null +++ b/src/client/components/users-dialog.vue @@ -0,0 +1,161 @@ +<template> +<x-modal ref="modal" @closed="() => { $emit('closed'); destroyDom(); }"> + <div class="mk-users-dialog"> + <div class="header"> + <span>{{ title }}</span> + <button class="_button" @click="close()"><fa :icon="faTimes"/></button> + </div> + + <sequential-entrance class="users"> + <router-link v-for="(item, i) in items" class="user" :key="item.id" :data-index="i" :to="extract ? extract(item) : item | userPage"> + <mk-avatar :user="extract ? extract(item) : item" class="avatar" :disable-link="true"/> + <div class="body"> + <mk-user-name :user="extract ? extract(item) : item" class="name"/> + <mk-acct :user="extract ? extract(item) : item" class="acct"/> + </div> + </router-link> + </sequential-entrance> + + <button class="more _button" v-if="more" @click="fetchMore" :disabled="moreFetching"> + <template v-if="!moreFetching">{{ $t('loadMore') }}</template> + <template v-if="moreFetching"><fa :icon="faSpinner" pulse fixed-width/></template> + </button> + + <p class="empty" v-if="empty">{{ $t('noUsers') }}</p> + + <mk-error v-if="error" @retry="init()"/> + </div> +</x-modal> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import paging from '../scripts/paging'; +import XModal from './modal.vue'; + +export default Vue.extend({ + i18n, + + components: { + XModal, + }, + + mixins: [ + paging({}), + ], + + props: { + title: { + required: true + }, + pagination: { + required: true + }, + extract: { + required: false + } + }, + + data() { + return { + faTimes + }; + }, + + methods: { + close() { + this.$refs.modal.close(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-users-dialog { + width: 350px; + height: 350px; + background: var(--panel); + border-radius: var(--radius); + overflow: hidden; + display: flex; + flex-direction: column; + + > .header { + display: flex; + flex-shrink: 0; + + > button { + height: 58px; + width: 58px; + + @media (max-width: 500px) { + height: 42px; + width: 42px; + } + } + + > span { + flex: 1; + line-height: 58px; + padding-left: 32px; + font-weight: bold; + + @media (max-width: 500px) { + line-height: 42px; + padding-left: 16px; + } + } + } + + > .users { + flex: 1; + overflow: auto; + + &:empty { + display: none; + } + + > .user { + display: flex; + align-items: center; + font-size: 14px; + padding: 8px 32px; + + @media (max-width: 500px) { + padding: 8px 16px; + } + + > * { + pointer-events: none; + user-select: none; + } + + > .avatar { + width: 45px; + height: 45px; + } + + > .body { + padding: 0 8px; + overflow: hidden; + + > .name { + display: block; + font-weight: bold; + } + + > .acct { + opacity: 0.5; + } + } + } + } + + > .empty { + text-align: center; + opacity: 0.5; + } +} +</style> diff --git a/src/client/components/visibility-chooser.vue b/src/client/components/visibility-chooser.vue new file mode 100644 index 0000000000..aa422b27dc --- /dev/null +++ b/src/client/components/visibility-chooser.vue @@ -0,0 +1,127 @@ +<template> +<x-popup :source="source" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }"> + <sequential-entrance class="gqyayizv" :delay="30"> + <button class="_button" @click="choose('public')" :class="{ active: v == 'public' }" data-index="0" key="0"> + <div><fa :icon="faGlobe"/></div> + <div> + <span>{{ $t('_visibility.public') }}</span> + <span>{{ $t('_visibility.publicDescription') }}</span> + </div> + </button> + <button class="_button" @click="choose('home')" :class="{ active: v == 'home' }" data-index="1" key="1"> + <div><fa :icon="faHome"/></div> + <div> + <span>{{ $t('_visibility.home') }}</span> + <span>{{ $t('_visibility.homeDescription') }}</span> + </div> + </button> + <button class="_button" @click="choose('followers')" :class="{ active: v == 'followers' }" data-index="2" key="2"> + <div><fa :icon="faUnlock"/></div> + <div> + <span>{{ $t('_visibility.followers') }}</span> + <span>{{ $t('_visibility.followersDescription') }}</span> + </div> + </button> + <button class="_button" @click="choose('specified')" :class="{ active: v == 'specified' }" data-index="3" key="3"> + <div><fa :icon="faEnvelope"/></div> + <div> + <span>{{ $t('_visibility.specified') }}</span> + <span>{{ $t('_visibility.specifiedDescription') }}</span> + </div> + </button> + </sequential-entrance> +</x-popup> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faGlobe, faUnlock, faHome } from '@fortawesome/free-solid-svg-icons'; +import { faEnvelope } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../i18n'; +import XPopup from './popup.vue'; + +export default Vue.extend({ + i18n, + components: { + XPopup + }, + props: { + source: { + required: true + }, + currentVisibility: { + type: String, + required: false + } + }, + data() { + return { + v: this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : (this.currentVisibility || this.$store.state.settings.defaultNoteVisibility), + faGlobe, faUnlock, faEnvelope, faHome + } + }, + methods: { + choose(visibility) { + if (this.$store.state.settings.rememberNoteVisibility) { + this.$store.commit('device/setVisibility', visibility); + } + this.$emit('chosen', visibility); + this.destroyDom(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.gqyayizv { + width: 240px; + padding: 8px 0; + + > button { + display: flex; + padding: 8px 14px; + font-size: 12px; + text-align: left; + width: 100%; + box-sizing: border-box; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:active { + background: rgba(0, 0, 0, 0.1); + } + + &.active { + color: #fff; + background: var(--accent); + } + + > *:first-child { + display: flex; + justify-content: center; + align-items: center; + margin-right: 10px; + width: 16px; + top: 0; + bottom: 0; + margin-top: auto; + margin-bottom: auto; + } + + > *:last-child { + flex: 1 1 auto; + + > span:first-child { + display: block; + font-weight: bold; + } + + > span:last-child:not(:first-child) { + opacity: 0.6; + } + } + } +} +</style> diff --git a/src/client/components/window.vue b/src/client/components/window.vue new file mode 100644 index 0000000000..bfdabee059 --- /dev/null +++ b/src/client/components/window.vue @@ -0,0 +1,155 @@ +<template> +<x-modal ref="modal" @closed="() => { $emit('closed'); destroyDom(); }"> + <div class="ebkgoccj" :class="{ noPadding }" @keydown="onKeydown"> + <div class="header"> + <button class="_button" v-if="withOkButton" @click="close()"><fa :icon="faTimes"/></button> + <span class="title"> + <mk-avatar :user="avatar" v-if="avatar" class="avatar"/> + <slot name="header"></slot> + </span> + <button class="_button" v-if="!withOkButton" @click="close()"><fa :icon="faTimes"/></button> + <button class="_button" v-if="withOkButton" @click="() => { $emit('ok'); close(); }" :disabled="okButtonDisabled"><fa :icon="faCheck"/></button> + </div> + <div class="body"> + <slot></slot> + </div> + </div> +</x-modal> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faTimes, faCheck } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import XModal from './modal.vue'; + +export default Vue.extend({ + i18n, + + components: { + XModal, + }, + + props: { + avatar: { + type: Object, + required: false + }, + withOkButton: { + type: Boolean, + required: false, + default: false + }, + okButtonDisabled: { + type: Boolean, + required: false, + default: false + }, + noPadding: { + type: Boolean, + required: false, + default: false + } + }, + + data() { + return { + faTimes, faCheck + }; + }, + + methods: { + close() { + this.$refs.modal.close(); + }, + + onKeydown(e) { + if (e.which === 27) { // Esc + e.preventDefault(); + e.stopPropagation(); + this.close(); + } + }, + } +}); +</script> + +<style lang="scss" scoped> +.ebkgoccj { + width: 400px; + height: 400px; + background: var(--panel); + border-radius: var(--radius); + overflow: hidden; + display: flex; + flex-direction: column; + + @media (max-width: 500px) { + width: 350px; + height: 350px; + } + + > .header { + $height: 58px; + $height-narrow: 42px; + display: flex; + flex-shrink: 0; + + > button { + height: $height; + width: $height; + + @media (max-width: 500px) { + height: $height-narrow; + width: $height-narrow; + } + } + + > .title { + flex: 1; + line-height: $height; + padding-left: 32px; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + + @media (max-width: 500px) { + line-height: $height-narrow; + padding-left: 16px; + } + + > .avatar { + $size: 32px; + height: $size; + width: $size; + margin: (($height - $size) / 2) 8px (($height - $size) / 2) 0; + + @media (max-width: 500px) { + $size: 24px; + height: $size; + width: $size; + margin: (($height-narrow - $size) / 2) 8px (($height-narrow - $size) / 2) 0; + } + } + } + + > button + .title { + padding-left: 0; + } + } + + > .body { + overflow: auto; + } + + &:not(.noPadding) > .body { + padding: 0 32px 32px 32px; + + @media (max-width: 500px) { + padding: 0 16px 16px 16px; + } + } +} +</style> diff --git a/src/client/app/config.ts b/src/client/config.ts index 55c0c6b3a5..175a3f0b29 100644 --- a/src/client/app/config.ts +++ b/src/client/config.ts @@ -1,20 +1,18 @@ declare const _LANGS_: string[]; -declare const _COPYRIGHT_: string; declare const _VERSION_: string; -declare const _CODENAME_: string; declare const _ENV_: string; const address = new URL(location.href); +const siteName = document.querySelector('meta[property="og:site_name"]') as HTMLMetaElement; export const host = address.host; export const hostname = address.hostname; export const url = address.origin; export const apiUrl = url + '/api'; export const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss://') + '/streaming'; -export const lang = localStorage.getItem('lang') || window.lang; // windowは後方互換性のため +export const lang = localStorage.getItem('lang'); export const langs = _LANGS_; export const locale = JSON.parse(localStorage.getItem('locale')); -export const copyright = _COPYRIGHT_; export const version = _VERSION_; -export const codename = _CODENAME_; export const env = _ENV_; +export const instanceName = siteName && siteName.content ? siteName.content : 'Misskey'; diff --git a/src/client/app/common/views/directives/autocomplete.ts b/src/client/directives/autocomplete.ts index 3f68baa01b..3f68baa01b 100644 --- a/src/client/app/common/views/directives/autocomplete.ts +++ b/src/client/directives/autocomplete.ts diff --git a/src/client/app/desktop/views/directives/index.ts b/src/client/directives/index.ts index 324e07596d..2e05b52023 100644 --- a/src/client/app/desktop/views/directives/index.ts +++ b/src/client/directives/index.ts @@ -1,6 +1,10 @@ import Vue from 'vue'; import userPreview from './user-preview'; +import autocomplete from './autocomplete'; +import size from './size'; +Vue.directive('autocomplete', autocomplete); Vue.directive('userPreview', userPreview); Vue.directive('user-preview', userPreview); +Vue.directive('size', size); diff --git a/src/client/directives/size.ts b/src/client/directives/size.ts new file mode 100644 index 0000000000..c7d797e5ae --- /dev/null +++ b/src/client/directives/size.ts @@ -0,0 +1,63 @@ +export default { + inserted(el, binding) { + const query = binding.value; + + /* + const addClassRecursive = (el: Element, cls: string) => { + el.classList.add(cls); + if (el.children) { + for (const child of el.children) { + addClassRecursive(child, cls); + } + } + }; + + const removeClassRecursive = (el: Element, cls: string) => { + el.classList.remove(cls); + if (el.children) { + for (const child of el.children) { + removeClassRecursive(child, cls); + } + } + };*/ + + const addClass = (el: Element, cls: string) => { + el.classList.add(cls); + }; + + const removeClass = (el: Element, cls: string) => { + el.classList.remove(cls); + }; + + const calc = () => { + const width = el.clientWidth; + + for (const q of query) { + if (q.max) { + if (width <= q.max) { + addClass(el, 'max-width_' + q.max + 'px'); + } else { + removeClass(el, 'max-width_' + q.max + 'px'); + } + } + if (q.min) { + if (width >= q.min) { + addClass(el, 'min-width_' + q.min + 'px'); + } else { + removeClass(el, 'min-width_' + q.min + 'px'); + } + } + } + }; + + calc(); + + el._sizeResizeCb_ = calc; + + window.addEventListener('resize', calc); + }, + + unbind(el, binding, vn) { + window.removeEventListener('resize', el._sizeResizeCb_); + } +}; diff --git a/src/client/app/desktop/views/directives/user-preview.ts b/src/client/directives/user-preview.ts index 8a4035881a..c3b4e7fce6 100644 --- a/src/client/app/desktop/views/directives/user-preview.ts +++ b/src/client/directives/user-preview.ts @@ -1,12 +1,8 @@ -/** - * マウスオーバーするとユーザーがプレビューされる要素を設定します - */ - import MkUserPreview from '../components/user-preview.vue'; export default { - bind(el, binding, vn) { - const self = el._userPreviewDirective_ = {} as any; + bind(el: HTMLElement, binding, vn) { + const self = (el as any)._userPreviewDirective_ = {} as any; self.user = binding.value; self.tag = null; @@ -26,28 +22,21 @@ export default { self.tag = new MkUserPreview({ parent: vn.context, propsData: { - user: self.user + user: self.user, + source: el } }).$mount(); - const preview = self.tag.$el; - const rect = el.getBoundingClientRect(); - const x = rect.left + el.offsetWidth + window.pageXOffset; - const y = rect.top + window.pageYOffset; - - preview.style.top = y + 'px'; - preview.style.left = x + 'px'; - - preview.addEventListener('mouseover', () => { + self.tag.$on('mouseover', () => { clearTimeout(self.hideTimer); }); - preview.addEventListener('mouseleave', () => { + self.tag.$on('mouseleave', () => { clearTimeout(self.showTimer); self.hideTimer = setTimeout(self.close, 500); }); - document.body.appendChild(preview); + document.body.appendChild(self.tag.$el); }; el.addEventListener('mouseover', () => { diff --git a/src/client/app/common/views/filters/bytes.ts b/src/client/filters/bytes.ts index 227ccae3a4..5b5d966cfd 100644 --- a/src/client/app/common/views/filters/bytes.ts +++ b/src/client/filters/bytes.ts @@ -2,7 +2,7 @@ import Vue from 'vue'; Vue.filter('bytes', (v, digits = 0) => { if (v == null) return '?'; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; if (v == 0) return '0'; const isMinus = v < 0; if (isMinus) v = -v; diff --git a/src/client/filters/index.ts b/src/client/filters/index.ts new file mode 100644 index 0000000000..1759c19c2c --- /dev/null +++ b/src/client/filters/index.ts @@ -0,0 +1,4 @@ +require('./bytes'); +require('./number'); +require('./user'); +require('./note'); diff --git a/src/client/app/common/views/filters/note.ts b/src/client/filters/note.ts index 3c9c8b7485..3c9c8b7485 100644 --- a/src/client/app/common/views/filters/note.ts +++ b/src/client/filters/note.ts diff --git a/src/client/app/common/views/filters/number.ts b/src/client/filters/number.ts index 8c799d9442..8c799d9442 100644 --- a/src/client/app/common/views/filters/number.ts +++ b/src/client/filters/number.ts diff --git a/src/client/app/common/views/filters/user.ts b/src/client/filters/user.ts index 9d4ae5c58b..e8f10c3db6 100644 --- a/src/client/app/common/views/filters/user.ts +++ b/src/client/filters/user.ts @@ -1,7 +1,7 @@ import Vue from 'vue'; -import getAcct from '../../../../../misc/acct/render'; -import getUserName from '../../../../../misc/get-user-name'; -import { url } from '../../../config'; +import getAcct from '../../misc/acct/render'; +import getUserName from '../../misc/get-user-name'; +import { url } from '../config'; Vue.filter('acct', user => { return getAcct(user); diff --git a/src/client/i18n.ts b/src/client/i18n.ts new file mode 100644 index 0000000000..05d319fbaf --- /dev/null +++ b/src/client/i18n.ts @@ -0,0 +1,12 @@ +import Vue from 'vue'; +import VueI18n from 'vue-i18n'; +import { lang, locale } from './config'; + +Vue.use(VueI18n); + +export default new VueI18n({ + locale: lang, + messages: { + [lang]: locale + } +}); diff --git a/src/client/init.ts b/src/client/init.ts new file mode 100644 index 0000000000..3ea95aa96b --- /dev/null +++ b/src/client/init.ts @@ -0,0 +1,199 @@ +/** + * App entry point + */ + +import Vue from 'vue'; +import Vuex from 'vuex'; +import VueMeta from 'vue-meta'; +import PortalVue from 'portal-vue'; +import VAnimateCss from 'v-animate-css'; +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; + +import i18n from './i18n'; +import VueHotkey from './scripts/hotkey'; +import App from './app.vue'; +import MiOS from './mios'; +import { version, langs, instanceName } from './config'; +import PostFormDialog from './components/post-form-dialog.vue'; +import Dialog from './components/dialog.vue'; +import Menu from './components/menu.vue'; +import { router } from './router'; +import { applyTheme, lightTheme } from './theme'; + +Vue.use(Vuex); +Vue.use(VueHotkey); +Vue.use(VueMeta); +Vue.use(PortalVue); +Vue.use(VAnimateCss); +Vue.component('fa', FontAwesomeIcon); + +require('./directives'); +require('./components'); +require('./widgets'); +require('./filters'); + +Vue.mixin({ + methods: { + destroyDom() { + this.$destroy(); + + if (this.$el.parentNode) { + this.$el.parentNode.removeChild(this.$el); + } + } + } +}); + +console.info(`Misskey v${version}`); + +// v11互換性のため +if (localStorage.getItem('kyoppie') === 'yuppie') { + localStorage.clear(); + location.reload(true); +} + +if (localStorage.getItem('theme') == null) { + applyTheme(lightTheme); +} + +//#region Detect the user language +let lang = null; + +if (langs.map(x => x[0]).includes(navigator.language)) { + lang = navigator.language; +} else { + lang = langs.map(x => x[0]).find(x => x.split('-')[0] == navigator.language); + + if (lang == null) { + // Fallback + lang = 'en-US'; + } +} + +localStorage.setItem('lang', lang); +//#endregion + +// Detect the user agent +const ua = navigator.userAgent.toLowerCase(); +let isMobile = /mobile|iphone|ipad|android/.test(ua); + +// Get the <head> element +const head = document.getElementsByTagName('head')[0]; + +// If mobile, insert the viewport meta tag +if (isMobile || window.innerWidth <= 1024) { + const viewport = document.getElementsByName("viewport").item(0); + viewport.setAttribute('content', + `${viewport.getAttribute('content')},minimum-scale=1,maximum-scale=1,user-scalable=no`); + head.appendChild(viewport); +} + +//#region Fetch locale data +const cachedLocale = localStorage.getItem('locale'); + +if (cachedLocale == null) { + fetch(`/assets/locales/${lang}.${version}.json`) + .then(response => response.json()).then(locale => { + localStorage.setItem('locale', JSON.stringify(locale)); + i18n.locale = lang; + i18n.setLocaleMessage(lang, locale); + }); +} else { + // TODO: 古い時だけ更新 + setTimeout(() => { + fetch(`/assets/locales/${lang}.${version}.json`) + .then(response => response.json()).then(locale => { + localStorage.setItem('locale', JSON.stringify(locale)); + }); + }, 1000 * 5); +} +//#endregion + +//#region Set lang attr +const html = document.documentElement; +html.setAttribute('lang', lang); +//#endregion + +// iOSでプライベートモードだとlocalStorageが使えないので既存のメソッドを上書きする +try { + localStorage.setItem('foo', 'bar'); +} catch (e) { + Storage.prototype.setItem = () => { }; // noop +} + +// http://qiita.com/junya/items/3ff380878f26ca447f85 +document.body.setAttribute('ontouchstart', ''); + +// アプリ基底要素マウント +document.body.innerHTML = '<div id="app"></div>'; + +const os = new MiOS(); + +os.init(async () => { + if (os.store.state.settings.wallpaper) document.documentElement.style.backgroundImage = `url(${os.store.state.settings.wallpaper})`; + + if ('Notification' in window && os.store.getters.isSignedIn) { + // 許可を得ていなかったらリクエスト + if (Notification.permission === 'default') { + Notification.requestPermission(); + } + } + + const app = new Vue({ + store: os.store, + metaInfo: { + title: null, + titleTemplate: title => title ? `${title} | ${instanceName}` : instanceName + }, + data() { + return { + stream: os.stream, + isMobile: isMobile + }; + }, + methods: { + api: os.api, + getMeta: os.getMeta, + getMetaSync: os.getMetaSync, + signout: os.signout, + new(vm, props) { + const x = new vm({ + parent: this, + propsData: props + }).$mount(); + document.body.appendChild(x.$el); + return x; + }, + dialog(opts) { + const vm = this.new(Dialog, opts); + const p: any = new Promise((res) => { + vm.$once('ok', result => res({ canceled: false, result })); + vm.$once('cancel', () => res({ canceled: true })); + }); + p.close = () => { + vm.close(); + }; + return p; + }, + menu(opts) { + const vm = this.new(Menu, opts); + const p: any = new Promise((res) => { + vm.$once('closed', () => res()); + }); + return p; + }, + post(opts, cb) { + const vm = this.new(PostFormDialog, opts); + if (cb) vm.$once('closed', cb); + (vm as any).focus(); + }, + }, + router: router, + render: createEl => createEl(App) + }); + + os.app = app; + + // マウント + app.$mount('#app'); +}); diff --git a/src/client/app/mios.ts b/src/client/mios.ts index 2c62f120ea..282c51185f 100644 --- a/src/client/app/mios.ts +++ b/src/client/mios.ts @@ -1,14 +1,12 @@ import autobind from 'autobind-decorator'; import Vue from 'vue'; import { EventEmitter } from 'eventemitter3'; -import { v4 as uuid } from 'uuid'; import initStore from './store'; import { apiUrl, version, locale } from './config'; -import Progress from './common/scripts/loading'; +import Progress from './scripts/loading'; -import Err from './common/views/components/connect-failed.vue'; -import Stream from './common/scripts/stream'; +import Stream from './scripts/stream'; //#region api requests let spinner = null; @@ -27,26 +25,10 @@ export default class MiOS extends EventEmitter { chachedAt: Date; }; - public get instanceName() { - const siteName = document.querySelector('meta[property="og:site_name"]') as HTMLMetaElement; - if (siteName && siteName.content) { - return siteName.content; - } - - return 'Misskey'; - } - private isMetaFetching = false; public app: Vue; - /** - * Whether is debug mode - */ - public get debug() { - return this.store ? this.store.state.device.debug : false; - } - public store: ReturnType<typeof initStore>; /** @@ -59,54 +41,6 @@ export default class MiOS extends EventEmitter { */ private swRegistration: ServiceWorkerRegistration = null; - /** - * Whether should register ServiceWorker - */ - private shouldRegisterSw: boolean; - - /** - * ウィンドウシステム - */ - public windows = new WindowSystem(); - - /** - * MiOSインスタンスを作成します - * @param shouldRegisterSw ServiceWorkerを登録するかどうか - */ - constructor(shouldRegisterSw = false) { - super(); - - this.shouldRegisterSw = shouldRegisterSw; - - if (this.debug) { - (window as any).os = this; - } - } - - @autobind - public log(...args) { - if (!this.debug) return; - console.log.apply(null, args); - } - - @autobind - public logInfo(...args) { - if (!this.debug) return; - console.info.apply(null, args); - } - - @autobind - public logWarn(...args) { - if (!this.debug) return; - console.warn.apply(null, args); - } - - @autobind - public logError(...args) { - if (!this.debug) return; - console.error.apply(null, args); - } - @autobind public signout() { this.store.dispatch('logout'); @@ -154,10 +88,7 @@ export default class MiOS extends EventEmitter { // When failure .catch(() => { // Render the error screen - document.body.innerHTML = '<div id="err"></div>'; - new Vue({ - render: createEl => createEl(Err) - }).$mount('#err'); + document.body.innerHTML = '<div id="err">Error</div>'; Progress.done(); }); @@ -177,11 +108,9 @@ export default class MiOS extends EventEmitter { callback(); // Init service worker - if (this.shouldRegisterSw) { - this.getMeta().then(data => { - if (data.swPublickey) this.registerSw(data.swPublickey); - }); - } + this.getMeta().then(data => { + if (data.swPublickey) this.registerSw(data.swPublickey); + }); }; // キャッシュがあったとき @@ -199,9 +128,9 @@ export default class MiOS extends EventEmitter { this.store.dispatch('mergeMe', freshData); }); } else { - // Get token from cookie or localStorage - const i = (document.cookie.match(/i=(\w+)/) || [null, null])[1] || localStorage.getItem('i'); - + // Get token from localStorage + const i = localStorage.getItem('i'); + fetchme(i, me => { if (me) { this.store.dispatch('login', me); @@ -240,18 +169,6 @@ export default class MiOS extends EventEmitter { }); }); - main.on('readAllMessagingMessages', () => { - this.store.dispatch('mergeMe', { - hasUnreadMessagingMessage: false - }); - }); - - main.on('unreadMessagingMessage', () => { - this.store.dispatch('mergeMe', { - hasUnreadMessagingMessage: true - }); - }); - main.on('unreadMention', () => { this.store.dispatch('mergeMe', { hasUnreadMentions: true @@ -276,6 +193,36 @@ export default class MiOS extends EventEmitter { }); }); + main.on('readAllMessagingMessages', () => { + this.store.dispatch('mergeMe', { + hasUnreadMessagingMessage: false + }); + }); + + main.on('unreadMessagingMessage', () => { + this.store.dispatch('mergeMe', { + hasUnreadMessagingMessage: true + }); + }); + + main.on('readAllAntennas', () => { + this.store.dispatch('mergeMe', { + hasUnreadAntenna: false + }); + }); + + main.on('unreadAntenna', () => { + this.store.dispatch('mergeMe', { + hasUnreadAntenna: true + }); + }); + + main.on('readAllAnnouncements', () => { + this.store.dispatch('mergeMe', { + hasUnreadAnnouncement: false + }); + }); + main.on('clientSettingUpdated', x => { this.store.commit('settings/set', { key: x.key, @@ -309,8 +256,6 @@ export default class MiOS extends EventEmitter { // When service worker activated navigator.serviceWorker.ready.then(registration => { - this.log('[sw] ready: ', registration); - this.swRegistration = registration; // Options of pushManager.subscribe @@ -327,8 +272,6 @@ export default class MiOS extends EventEmitter { // Subscribe push notification this.swRegistration.pushManager.subscribe(opts).then(subscription => { - this.log('[sw] Subscribe OK:', subscription); - function encode(buffer: ArrayBuffer) { return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))); } @@ -342,11 +285,8 @@ export default class MiOS extends EventEmitter { }) // When subscribe failed .catch(async (err: Error) => { - this.logError('[sw] Subscribe Error:', err); - // 通知が許可されていなかったとき if (err.name == 'NotAllowedError') { - this.logError('[sw] Subscribe failed due to notification not allowed'); return; } @@ -362,69 +302,40 @@ export default class MiOS extends EventEmitter { const sw = `/sw.${version}.js`; // Register service worker - navigator.serviceWorker.register(sw).then(registration => { - // 登録成功 - this.logInfo('[sw] Registration successful with scope: ', registration.scope); - }).catch(err => { - // 登録失敗 :( - this.logError('[sw] Registration failed: ', err); - }); + navigator.serviceWorker.register(sw); } - public requests = []; - /** * Misskey APIにリクエストします * @param endpoint エンドポイント名 * @param data パラメータ */ @autobind - public api(endpoint: string, data: { [x: string]: any } = {}, silent = false): Promise<{ [x: string]: any }> { - if (!silent) { - if (++pending === 1) { - spinner = document.createElement('div'); - spinner.setAttribute('id', 'wait'); - document.body.appendChild(spinner); - } + public api(endpoint: string, data: { [x: string]: any } = {}, token?): Promise<{ [x: string]: any }> { + if (++pending === 1) { + spinner = document.createElement('div'); + spinner.setAttribute('id', 'wait'); + document.body.appendChild(spinner); } const onFinally = () => { - if (!silent) { - if (--pending === 0) spinner.parentNode.removeChild(spinner); - } + if (--pending === 0) spinner.parentNode.removeChild(spinner); }; const promise = new Promise((resolve, reject) => { // Append a credential if (this.store.getters.isSignedIn) (data as any).i = this.store.state.i.token; - - const req = { - id: uuid(), - date: new Date(), - name: endpoint, - data, - res: null, - status: null - }; - - if (this.debug) { - this.requests.push(req); - } + if (token) (data as any).i = token; // Send request fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, { method: 'POST', body: JSON.stringify(data), - credentials: endpoint === 'signin' ? 'include' : 'omit', + credentials: 'omit', cache: 'no-cache' }).then(async (res) => { const body = res.status === 204 ? null : await res.json(); - if (this.debug) { - req.status = res.status; - req.res = body; - } - if (res.status === 200) { resolve(body); } else if (res.status === 204) { @@ -484,24 +395,6 @@ export default class MiOS extends EventEmitter { } } -class WindowSystem extends EventEmitter { - public windows = new Set(); - - public add(window) { - this.windows.add(window); - this.emit('added', window); - } - - public remove(window) { - this.windows.delete(window); - this.emit('removed', window); - } - - public getAll() { - return this.windows; - } -} - /** * Convert the URL safe base64 string to a Uint8Array * @param base64String base64 string diff --git a/src/client/pages/about.vue b/src/client/pages/about.vue new file mode 100644 index 0000000000..e47856bb94 --- /dev/null +++ b/src/client/pages/about.vue @@ -0,0 +1,106 @@ +<template> +<div class="mmnnbwxb"> + <portal to="icon"><fa :icon="faInfoCircle"/></portal> + <portal to="title">{{ $t('about') }}</portal> + + <section class="_section info" v-if="meta"> + <div class="_title"><fa :icon="faInfoCircle"/> {{ $t('instanceInfo') }}</div> + <div class="_content" v-if="meta.description"> + <div>{{ meta.description }}</div> + </div> + <div class="_content table"> + <div><b>{{ $t('administrator') }}</b><span>{{ meta.maintainerName }}</span></div> + <div><b></b><span>{{ meta.maintainerEmail }}</span></div> + </div> + <div class="_content table" v-if="stats"> + <div><b>{{ $t('users') }}</b><span>{{ stats.originalUsersCount | number }}</span></div> + <div><b>{{ $t('notes') }}</b><span>{{ stats.originalNotesCount | number }}</span></div> + </div> + <div class="_content table"> + <div><b>Misskey</b><span>v{{ version }}</span></div> + </div> + </section> + + <section class="_section aboutMisskey"> + <div class="_title"><fa :icon="faInfoCircle"/> {{ $t('aboutMisskey') }}</div> + <div class="_content"> + <div style="margin-bottom: 1em;">{{ $t('aboutMisskeyText') }}</div> + <div>{{ $t('misskeyMembers') }}</div> + <span class="members"> + <a href="https://github.com/syuilo" target="_blank">@syuilo</a> + <a href="https://github.com/AyaMorisawa" target="_blank">@AyaMorisawa</a> + <a href="https://github.com/mei23" target="_blank">@mei23</a> + <a href="https://github.com/acid-chicken" target="_blank">@acid-chicken</a> + <a href="https://github.com/tamaina" target="_blank">@tamaina</a> + <a href="https://github.com/rinsuki" target="_blank">@rinsuki</a> + </span> + <div style="margin-top: 1em;">{{ $t('misskeySource') }}</div> + <a href="https://github.com/syuilo/misskey" target="_blank" style="color: var(--link);">https://github.com/syuilo/misskey</a> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'; +import { version } from '../config'; +import i18n from '../i18n'; + +export default Vue.extend({ + i18n, + + metaInfo() { + return { + title: this.$t('instance') as string + }; + }, + + data() { + return { + version, + meta: null, + stats: null, + serverInfo: null, + faInfoCircle + } + }, + + created() { + this.$root.getMeta().then(meta => { + this.meta = meta; + }); + + this.$root.api('stats').then(res => { + this.stats = res; + }); + }, +}); +</script> + +<style lang="scss" scoped> +.mmnnbwxb { + > .info { + > .table { + > div { + display: flex; + + > * { + flex: 1; + } + } + } + } + + > .aboutMisskey { + > ._content { + > .members { + > a { + color: var(--link); + margin-right: 0.5em; + } + } + } + } +} +</style> diff --git a/src/client/pages/announcements.vue b/src/client/pages/announcements.vue new file mode 100644 index 0000000000..586bc0c03c --- /dev/null +++ b/src/client/pages/announcements.vue @@ -0,0 +1,73 @@ +<template> +<div> + <portal to="icon"><fa :icon="faBroadcastTower"/></portal> + <portal to="title">{{ $t('announcements') }}</portal> + + <mk-pagination :pagination="pagination" #default="{items}" class="ruryvtyk" ref="list"> + <section class="_section announcement" v-for="(announcement, i) in items" :key="announcement.id" :data-index="i"> + <div class="_title"><span v-if="$store.getters.isSignedIn && !announcement.isRead">🆕 </span>{{ announcement.title }}</div> + <div class="_content"> + <mfm :text="announcement.text"/> + <img v-if="announcement.imageUrl" :src="announcement.imageUrl" alt=""/> + </div> + <div class="_footer" v-if="$store.getters.isSignedIn && !announcement.isRead"> + <mk-button @click="read(announcement)" primary><fa :icon="faCheck"/> {{ $t('gotIt') }}</mk-button> + </div> + </section> + </mk-pagination> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faCheck, faBroadcastTower } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import MkPagination from '../components/ui/pagination.vue'; +import MkButton from '../components/ui/button.vue'; + +export default Vue.extend({ + i18n, + + metaInfo() { + return { + title: this.$t('announcements') as string + }; + }, + + components: { + MkPagination, + MkButton + }, + + data() { + return { + pagination: { + endpoint: 'announcements', + limit: 10, + }, + faCheck, faBroadcastTower + }; + }, + + methods: { + read(announcement) { + announcement.isRead = true; + this.$root.api('i/read-announcement', { announcementId: announcement.id }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.ruryvtyk { + > .announcement { + > ._content { + > img { + display: block; + max-height: 300px; + max-width: 100%; + } + } + } +} +</style> diff --git a/src/client/pages/auth.form.vue b/src/client/pages/auth.form.vue new file mode 100644 index 0000000000..80a792eb36 --- /dev/null +++ b/src/client/pages/auth.form.vue @@ -0,0 +1,63 @@ +<template> +<section class="_section"> + <div class="_title">{{ $t('_auth.shareAccess', { name: app.name }) }}</div> + <div class="_content"> + <h2>{{ app.name }}</h2> + <p class="id">{{ app.id }}</p> + <p class="description">{{ app.description }}</p> + </div> + <div class="_content"> + <h2>{{ $t('_auth.permissionAsk') }}</h2> + <ul> + <template v-for="p in app.permission"> + <li :key="p">{{ $t(`_permissions.${p}`) }}</li> + </template> + </ul> + </div> + <div class="_footer"> + <mk-button @click="cancel" inline>{{ $t('cancel') }}</mk-button> + <mk-button @click="accept" inline primary>{{ $t('accept') }}</mk-button> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import MkButton from '../components/ui/button.vue'; + +export default Vue.extend({ + i18n, + components: { + MkButton + }, + props: ['session'], + computed: { + name(): string { + const el = document.createElement('div'); + el.textContent = this.app.name + return el.innerHTML; + }, + app(): any { + return this.session.app; + } + }, + methods: { + cancel() { + this.$root.api('auth/deny', { + token: this.session.token + }).then(() => { + this.$emit('denied'); + }); + }, + + accept() { + this.$root.api('auth/accept', { + token: this.session.token + }).then(() => { + this.$emit('accepted'); + }); + } + } +}); +</script> diff --git a/src/client/pages/auth.vue b/src/client/pages/auth.vue new file mode 100644 index 0000000000..15ec81e019 --- /dev/null +++ b/src/client/pages/auth.vue @@ -0,0 +1,93 @@ +<template> +<div class="_panel" v-if="$store.getters.isSignedIn && fetching"> + <mk-loading/> +</div> +<div v-else-if="$store.getters.isSignedIn"> + <x-form + class="form" + ref="form" + v-if="state == 'waiting'" + :session="session" + @denied="state = 'denied'" + @accepted="accepted" + /> + <div class="denied _panel" v-if="state == 'denied'"> + <h1>{{ $t('denied') }}</h1> + <p>{{ $t('denied-paragraph') }}</p> + </div> + <div class="accepted _panel" v-if="state == 'accepted'"> + <h1>{{ session.app.isAuthorized ? this.$t('already-authorized') : this.$t('allowed') }}</h1> + <p v-if="session.app.callbackUrl">{{ $t('callback-url') }}<mk-ellipsis/></p> + <p v-if="!session.app.callbackUrl">{{ $t('please-go-back') }}</p> + </div> + <div class="error _panel" v-if="state == 'fetch-session-error'"> + <p>{{ $t('error') }}</p> + </div> +</div> +<div class="signin" v-else> + <h1>{{ $t('sign-in') }}</h1> + <mk-signin/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import XForm from './auth.form.vue'; + +export default Vue.extend({ + i18n, + components: { + XForm + }, + data() { + return { + state: null, + session: null, + fetching: true + }; + }, + computed: { + token(): string { + return this.$route.params.token; + } + }, + mounted() { + if (!this.$store.getters.isSignedIn) return; + + // Fetch session + this.$root.api('auth/session/show', { + token: this.token + }).then(session => { + this.session = session; + this.fetching = false; + + // 既に連携していた場合 + if (this.session.app.isAuthorized) { + this.$root.api('auth/accept', { + token: this.session.token + }).then(() => { + this.accepted(); + }); + } else { + this.state = 'waiting'; + } + }).catch(error => { + this.state = 'fetch-session-error'; + this.fetching = false; + }); + }, + methods: { + accepted() { + this.state = 'accepted'; + if (this.session.app.callbackUrl) { + location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`; + } + } + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/src/client/pages/drive.vue b/src/client/pages/drive.vue new file mode 100644 index 0000000000..24a0d91ff6 --- /dev/null +++ b/src/client/pages/drive.vue @@ -0,0 +1,87 @@ +<template> +<div> + <portal to="header"> + <button @click="menu" class="_button _jmoebdiw_"> + <fa :icon="faCloud" style="margin-right: 8px;"/> + <span v-if="folder">{{ $t('drive') }} ({{ folder.name }})</span> + <span v-else>{{ $t('drive') }}</span> + <fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/> + </button> + </portal> + <x-drive ref="drive" @cd="x => folder = x"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faCloud, faAngleDown, faAngleUp, faFolderPlus, faUpload, faLink, faICursor, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; +import XDrive from '../components/drive.vue'; + +export default Vue.extend({ + metaInfo() { + return { + title: this.$t('drive') as string + }; + }, + + components: { + XDrive + }, + + data() { + return { + menuOpened: false, + folder: null, + faCloud, faAngleDown, faAngleUp + }; + }, + + methods: { + menu(ev) { + this.menuOpened = true; + this.$root.menu({ + items: [{ + text: this.$t('addFile'), + type: 'label' + }, { + text: this.$t('upload'), + icon: faUpload, + action: () => { this.$refs.drive.selectLocalFile(); } + }, { + text: this.$t('fromUrl'), + icon: faLink, + action: () => { this.$refs.drive.urlUpload(); } + }, null, { + text: this.folder ? this.folder.name : this.$t('drive'), + type: 'label' + }, this.folder ? { + text: this.$t('renameFolder'), + icon: faICursor, + action: () => { this.$refs.drive.renameFolder(); } + } : undefined, this.folder ? { + text: this.$t('deleteFolder'), + icon: faTrashAlt, + action: () => { this.$refs.drive.deleteFolder(); } + } : undefined, { + text: this.$t('createFolder'), + icon: faFolderPlus, + action: () => { this.$refs.drive.createFolder(); } + }], + fixed: true, + noCenter: true, + source: ev.currentTarget || ev.target + }).then(() => { + this.menuOpened = false; + }); + } + } +}); +</script> + +<style lang="scss"> +._jmoebdiw_ { + height: 100%; + padding: 0 16px; + font-weight: bold; +} +</style> diff --git a/src/client/pages/explore.vue b/src/client/pages/explore.vue new file mode 100644 index 0000000000..ba2c3faa6c --- /dev/null +++ b/src/client/pages/explore.vue @@ -0,0 +1,212 @@ +<template> +<div> + <portal to="icon"><fa :icon="faHashtag"/></portal> + <portal to="title">{{ $t('explore') }}</portal> + + <div class="localfedi7 _panel" v-if="meta && stats && tag == null" :style="{ backgroundImage: meta.bannerUrl ? `url(${meta.bannerUrl})` : null }"> + <header><span>{{ $t('explore', { host: meta.name || 'Misskey' }) }}</span></header> + <div><span>{{ $t('exploreUsersCount', { count: num(stats.originalUsersCount) }) }}</span></div> + </div> + + <template v-if="tag == null"> + <x-user-list :pagination="pinnedUsers" :expanded="false"> + <fa :icon="faBookmark" fixed-width/>{{ $t('pinnedUsers') }} + </x-user-list> + <x-user-list :pagination="popularUsers" :expanded="false"> + <fa :icon="faChartLine" fixed-width/>{{ $t('popularUsers') }} + </x-user-list> + <x-user-list :pagination="recentlyUpdatedUsers" :expanded="false"> + <fa :icon="faCommentAlt" fixed-width/>{{ $t('recentlyUpdatedUsers') }} + </x-user-list> + <x-user-list :pagination="recentlyRegisteredUsers" :expanded="false"> + <fa :icon="faPlus" fixed-width/>{{ $t('recentlyRegisteredUsers') }} + </x-user-list> + </template> + + <div class="localfedi7 _panel" v-if="tag == null" :style="{ backgroundImage: `url(/assets/fedi.jpg)`, marginTop: 'var(--margin)' }"> + <header><span>{{ $t('exploreFediverse') }}</span></header> + </div> + + <mk-container :body-togglable="true" :expanded="false" ref="tags"> + <template #header><fa :icon="faHashtag" fixed-width/>{{ $t('popularTags') }}</template> + + <div class="vxjfqztj"> + <router-link v-for="tag in tagsLocal" :to="`/explore/tags/${tag.tag}`" :key="'local:' + tag.tag" class="local">{{ tag.tag }}</router-link> + <router-link v-for="tag in tagsRemote" :to="`/explore/tags/${tag.tag}`" :key="'remote:' + tag.tag">{{ tag.tag }}</router-link> + </div> + </mk-container> + + <x-user-list v-if="tag != null" :pagination="tagUsers" :key="`${tag}`"> + <fa :icon="faHashtag" fixed-width/>{{ tag }} + </x-user-list> + <template v-if="tag == null"> + <x-user-list :pagination="popularUsersF" :expanded="false"> + <fa :icon="faChartLine" fixed-width/>{{ $t('popularUsers') }} + </x-user-list> + <x-user-list :pagination="recentlyUpdatedUsersF" :expanded="false"> + <fa :icon="faCommentAlt" fixed-width/>{{ $t('recentlyUpdatedUsers') }} + </x-user-list> + <x-user-list :pagination="recentlyRegisteredUsersF" :expanded="false"> + <fa :icon="faRocket" fixed-width/>{{ $t('recentlyDiscoveredUsers') }} + </x-user-list> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faChartLine, faPlus, faHashtag, faRocket } from '@fortawesome/free-solid-svg-icons'; +import { faBookmark, faCommentAlt } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../i18n'; +import XUserList from '../components/user-list.vue'; +import MkContainer from '../components/ui/container.vue'; + +export default Vue.extend({ + i18n, + + metaInfo() { + return { + title: this.$t('explore') as string + }; + }, + + components: { + XUserList, + MkContainer, + }, + + props: { + tag: { + type: String, + required: false + } + }, + + data() { + return { + pinnedUsers: { endpoint: 'pinned-users' }, + popularUsers: { endpoint: 'users', limit: 10, noPaging: true, params: { + state: 'alive', + origin: 'local', + sort: '+follower', + } }, + recentlyUpdatedUsers: { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'local', + sort: '+updatedAt', + } }, + recentlyRegisteredUsers: { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'local', + state: 'alive', + sort: '+createdAt', + } }, + popularUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: { + state: 'alive', + origin: 'remote', + sort: '+follower', + } }, + recentlyUpdatedUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'combined', + sort: '+updatedAt', + } }, + recentlyRegisteredUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'combined', + sort: '+createdAt', + } }, + tagsLocal: [], + tagsRemote: [], + stats: null, + meta: null, + num: Vue.filter('number'), + faBookmark, faChartLine, faCommentAlt, faPlus, faHashtag, faRocket + }; + }, + + computed: { + tagUsers(): any { + return { + endpoint: 'hashtags/users', + limit: 30, + params: { + tag: this.tag, + origin: 'combined', + sort: '+follower', + } + }; + }, + }, + + watch: { + tag() { + if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null); + } + }, + + created() { + this.$root.api('hashtags/list', { + sort: '+attachedLocalUsers', + attachedToLocalUserOnly: true, + limit: 30 + }).then(tags => { + this.tagsLocal = tags; + }); + this.$root.api('hashtags/list', { + sort: '+attachedRemoteUsers', + attachedToRemoteUserOnly: true, + limit: 30 + }).then(tags => { + this.tagsRemote = tags; + }); + this.$root.api('stats').then(stats => { + this.stats = stats; + }); + this.$root.getMeta().then(meta => { + this.meta = meta; + }); + }, +}); +</script> + +<style lang="scss" scoped> +.localfedi7 { + color: #fff; + padding: 16px; + height: 80px; + background-position: 50%; + background-size: cover; + margin-bottom: var(--margin); + + > * { + &:not(:last-child) { + margin-bottom: 8px; + } + + > span { + display: inline-block; + padding: 6px 8px; + background: rgba(0, 0, 0, 0.7); + } + } + + > header { + font-size: 20px; + font-weight: bold; + } + + > div { + font-size: 14px; + opacity: 0.8; + } +} + +.vxjfqztj { + padding: 16px; + + > * { + margin-right: 16px; + + &.local { + font-weight: bold; + } + } +} +</style> diff --git a/src/client/pages/favorites.vue b/src/client/pages/favorites.vue new file mode 100644 index 0000000000..59bef2ca91 --- /dev/null +++ b/src/client/pages/favorites.vue @@ -0,0 +1,48 @@ +<template> +<div> + <portal to="icon"><fa :icon="faStar"/></portal> + <portal to="title">{{ $t('favorites') }}</portal> + <x-notes :pagination="pagination" :detail="true" :extract="items => items.map(item => item.note)" @before="before()" @after="after()"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faStar } from '@fortawesome/free-solid-svg-icons'; +import Progress from '../scripts/loading'; +import XNotes from '../components/notes.vue'; + +export default Vue.extend({ + metaInfo() { + return { + title: this.$t('favorites') as string + }; + }, + + components: { + XNotes + }, + + data() { + return { + pagination: { + endpoint: 'i/favorites', + limit: 10, + params: () => ({ + }) + }, + faStar + }; + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/src/client/pages/featured.vue b/src/client/pages/featured.vue new file mode 100644 index 0000000000..e6293e9e83 --- /dev/null +++ b/src/client/pages/featured.vue @@ -0,0 +1,47 @@ +<template> +<div> + <portal to="icon"><fa :icon="faFireAlt"/></portal> + <portal to="title">{{ $t('featured') }}</portal> + <x-notes ref="notes" :pagination="pagination" @before="before" @after="after"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faFireAlt } from '@fortawesome/free-solid-svg-icons'; +import Progress from '../scripts/loading'; +import XNotes from '../components/notes.vue'; + +export default Vue.extend({ + metaInfo() { + return { + title: this.$t('featured') as string + }; + }, + + components: { + XNotes + }, + + data() { + return { + pagination: { + endpoint: 'notes/featured', + limit: 10, + offsetMode: true + }, + faFireAlt + }; + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/src/client/pages/follow-requests.vue b/src/client/pages/follow-requests.vue new file mode 100644 index 0000000000..c302088b97 --- /dev/null +++ b/src/client/pages/follow-requests.vue @@ -0,0 +1,142 @@ +<template> +<mk-pagination :pagination="pagination" #default="{items}" class="mk-follow-requests" ref="list"> + <div class="user _panel" v-for="(req, i) in items" :key="req.id" :data-index="i"> + <mk-avatar class="avatar" :user="req.follower"/> + <div class="body"> + <div class="name"> + <router-link class="name" :to="req.follower | userPage" v-user-preview="req.follower.id"><mk-user-name :user="req.follower"/></router-link> + <p class="acct">@{{ req.follower | acct }}</p> + </div> + <div class="description" v-if="req.follower.description" :title="req.follower.description"> + <mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$store.state.i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/> + </div> + <div class="actions"> + <button class="_button" @click="accept(req.follower)"><fa :icon="faCheck"/></button> + <button class="_button" @click="reject(req.follower)"><fa :icon="faTimes"/></button> + </div> + </div> + </div> +</mk-pagination> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faCheck, faTimes } from '@fortawesome/free-solid-svg-icons'; +import MkPagination from '../components/ui/pagination.vue'; + +export default Vue.extend({ + metaInfo() { + return { + title: this.$t('followRequests') as string + }; + }, + + components: { + MkPagination + }, + + data() { + return { + pagination: { + endpoint: 'following/requests/list', + limit: 10, + }, + faCheck, faTimes + }; + }, + + methods: { + accept(user) { + this.$root.api('following/requests/accept', { userId: user.id }).then(() => { + this.$refs.list.reload(); + }); + }, + reject(user) { + this.$root.api('following/requests/reject', { userId: user.id }).then(() => { + this.$refs.list.reload(); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-follow-requests { + > .user { + display: flex; + padding: 16px; + + > .avatar { + display: block; + flex-shrink: 0; + margin: 0 12px 0 0; + width: 42px; + height: 42px; + border-radius: 8px; + } + + > .body { + display: flex; + width: calc(100% - 54px); + position: relative; + + > .name { + width: 45%; + + @media (max-width: 500px) { + width: 100%; + } + + > .name, + > .acct { + display: block; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + margin: 0; + } + + > .name { + font-size: 16px; + line-height: 24px; + } + + > .acct { + font-size: 15px; + line-height: 16px; + opacity: 0.7; + } + } + + > .description { + width: 55%; + line-height: 42px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.7; + font-size: 14px; + padding-right: 40px; + padding-left: 8px; + box-sizing: border-box; + + @media (max-width: 500px) { + display: none; + } + } + + > .actions { + position: absolute; + top: 0; + bottom: 0; + right: 0; + margin: auto 0; + + > button { + padding: 12px; + } + } + } + } +} +</style> diff --git a/src/client/pages/follow.vue b/src/client/pages/follow.vue new file mode 100644 index 0000000000..d765259737 --- /dev/null +++ b/src/client/pages/follow.vue @@ -0,0 +1,98 @@ +<template> +<div class="mk-follow-page"> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; + +export default Vue.extend({ + i18n, + + created() { + const acct = new URL(location.href).searchParams.get('acct'); + if (acct == null) return; + + const dialog = this.$root.dialog({ + type: 'waiting', + text: this.$t('fetchingAsApObject') + '...', + showOkButton: false, + showCancelButton: false, + cancelableByBgClick: false + }); + + if (acct.startsWith('https://')) { + this.$root.api('ap/show', { + uri: acct + }).then(res => { + if (res.type == 'User') { + this.follow(res.object); + } else { + this.$root.dialog({ + type: 'error', + text: 'Not a user' + }).then(() => { + window.close(); + }); + } + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }).then(() => { + window.close(); + }); + }).finally(() => { + dialog.close(); + }); + } else { + this.$root.api('users/show', parseAcct(acct)).then(user => { + this.follow(user); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }).then(() => { + window.close(); + }); + }).finally(() => { + dialog.close(); + }); + } + }, + + methods: { + async follow(user) { + const { canceled } = await this.$root.dialog({ + type: 'question', + text: this.$t('followConfirm', { name: user.name || user.username }), + showCancelButton: true + }); + + if (canceled) { + window.close(); + return; + } + + this.$root.api('following/create', { + userId: user.id + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }).then(() => { + window.close(); + }); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }).then(() => { + window.close(); + }); + }); + } + } +}); +</script> diff --git a/src/client/pages/index.home.vue b/src/client/pages/index.home.vue new file mode 100644 index 0000000000..0c3d2e7f86 --- /dev/null +++ b/src/client/pages/index.home.vue @@ -0,0 +1,190 @@ +<template> +<div class="mk-home" v-hotkey.global="keymap"> + <portal to="header"> + <button @click="choose" class="_button _kjvfvyph_"> + <i><fa v-if="$store.state.i.hasUnreadAntenna" :icon="faCircle"/></i> + <fa v-if="src === 'home'" :icon="faHome"/> + <fa v-if="src === 'local'" :icon="faComments"/> + <fa v-if="src === 'social'" :icon="faShareAlt"/> + <fa v-if="src === 'global'" :icon="faGlobe"/> + <fa v-if="src === 'list'" :icon="faListUl"/> + <fa v-if="src === 'antenna'" :icon="faSatellite"/> + <span style="margin-left: 8px;">{{ src === 'list' ? list.name : src === 'antenna' ? antenna.name : $t('_timelines.' + src) }}</span> + <fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/> + </button> + </portal> + <x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src" :src="src" :list="list" :antenna="antenna" @before="before()" @after="after()"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faListUl, faSatellite, faCircle } from '@fortawesome/free-solid-svg-icons'; +import { faComments } from '@fortawesome/free-regular-svg-icons'; +import Progress from '../scripts/loading'; +import XTimeline from '../components/timeline.vue'; + +export default Vue.extend({ + metaInfo() { + return { + title: this.$t('timeline') as string + }; + }, + + components: { + XTimeline + }, + + data() { + return { + src: 'home', + list: null, + antenna: null, + menuOpened: false, + faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faComments, faListUl, faSatellite, faCircle + }; + }, + + computed: { + keymap(): any { + return { + 't': this.focus + }; + } + }, + + watch: { + src() { + this.showNav = false; + this.saveSrc(); + }, + list(x) { + this.showNav = false; + this.saveSrc(); + if (x != null) this.antenna = null; + }, + antenna(x) { + this.showNav = false; + this.saveSrc(); + if (x != null) this.list = null; + }, + }, + + created() { + this.$root.getMeta().then((meta: Record<string, any>) => { + if (!( + this.enableGlobalTimeline = !meta.disableGlobalTimeline || this.$store.state.i.isModerator || this.$store.state.i.isAdmin + ) && this.src === 'global') this.src = 'local'; + if (!( + this.enableLocalTimeline = !meta.disableLocalTimeline || this.$store.state.i.isModerator || this.$store.state.i.isAdmin + ) && ['local', 'social'].includes(this.src)) this.src = 'home'; + }); + if (this.$store.state.device.tl) { + this.src = this.$store.state.device.tl.src; + if (this.src === 'list') { + this.list = this.$store.state.device.tl.arg; + } else if (this.src === 'antenna') { + this.antenna = this.$store.state.device.tl.arg; + } + } + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + }, + + async choose(ev) { + this.menuOpened = true; + const [antennas, lists] = await Promise.all([ + this.$root.api('antennas/list'), + this.$root.api('users/lists/list') + ]); + const antennaItems = antennas.map(antenna => ({ + text: antenna.name, + icon: faSatellite, + indicate: antenna.hasUnreadNote, + action: () => { + this.antenna = antenna; + this.setSrc('antenna'); + } + })); + const listItems = lists.map(list => ({ + text: list.name, + icon: faListUl, + action: () => { + this.list = list; + this.setSrc('list'); + } + })); + this.$root.menu({ + items: [{ + text: this.$t('_timelines.home'), + icon: faHome, + action: () => { this.setSrc('home') } + }, { + text: this.$t('_timelines.local'), + icon: faComments, + action: () => { this.setSrc('local') } + }, { + text: this.$t('_timelines.social'), + icon: faShareAlt, + action: () => { this.setSrc('social') } + }, { + text: this.$t('_timelines.global'), + icon: faGlobe, + action: () => { this.setSrc('global') } + }, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems], + fixed: true, + noCenter: true, + source: ev.currentTarget || ev.target + }).then(() => { + this.menuOpened = false; + }); + }, + + setSrc(src) { + this.src = src; + }, + + saveSrc() { + this.$store.commit('device/setTl', { + src: this.src, + arg: this.src == 'list' ? this.list : this.antenna + }); + }, + + focus() { + (this.$refs.tl as any).focus(); + } + } +}); +</script> + +<style lang="scss"> +@keyframes blink { + 0% { opacity: 1; } + 30% { opacity: 1; } + 90% { opacity: 0; } +} + +._kjvfvyph_ { + position: relative; + height: 100%; + padding: 0 16px; + font-weight: bold; + + > i { + position: absolute; + top: 16px; + right: 8px; + color: var(--accent); + font-size: 12px; + animation: blink 1s infinite; + } +} +</style> diff --git a/src/client/app/mobile/views/pages/index.vue b/src/client/pages/index.vue index 5d11fc5423..732d9b71cc 100644 --- a/src/client/app/mobile/views/pages/index.vue +++ b/src/client/pages/index.vue @@ -4,13 +4,12 @@ <script lang="ts"> import Vue from 'vue'; -import Home from './home.vue'; -import Welcome from './welcome.vue'; +import Home from './index.home.vue'; export default Vue.extend({ components: { Home, - Welcome + Welcome: () => import('./index.welcome.vue').then(m => m.default), } }); </script> diff --git a/src/client/pages/index.welcome.entrance.vue b/src/client/pages/index.welcome.entrance.vue new file mode 100644 index 0000000000..1b0cc7d034 --- /dev/null +++ b/src/client/pages/index.welcome.entrance.vue @@ -0,0 +1,103 @@ +<template> +<div class="rsqzvsbo"> + <div class="_panel about"> + <div class="banner" :style="{ backgroundImage: `url(${ banner })` }"></div> + <div class="body"> + <h1 class="name" v-html="name || host"></h1> + <div class="desc" v-html="description || $t('introMisskey')"></div> + <mk-button @click="signup()" style="display: inline-block; margin-right: 16px;" primary>{{ $t('signup') }}</mk-button> + <mk-button @click="signin()" style="display: inline-block;">{{ $t('login') }}</mk-button> + </div> + </div> + <x-notes :pagination="featuredPagination"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { toUnicode } from 'punycode'; +import XSigninDialog from '../components/signin-dialog.vue'; +import XSignupDialog from '../components/signup-dialog.vue'; +import MkButton from '../components/ui/button.vue'; +import XNotes from '../components/notes.vue'; +import i18n from '../i18n'; +import { host } from '../config'; + +export default Vue.extend({ + i18n, + + components: { + MkButton, + XNotes, + }, + + data() { + return { + featuredPagination: { + endpoint: 'notes/featured', + limit: 10, + noPaging: true, + }, + host: toUnicode(host), + meta: null, + name: null, + description: null, + banner: null, + announcements: [], + }; + }, + + created() { + this.$root.getMeta().then(meta => { + this.meta = meta; + this.name = meta.name; + this.description = meta.description; + this.announcements = meta.announcements; + this.banner = meta.bannerUrl; + }); + + this.$root.api('stats').then(stats => { + this.stats = stats; + }); + }, + + methods: { + signin() { + this.$root.new(XSigninDialog, { + autoSet: true + }); + }, + + signup() { + this.$root.new(XSignupDialog); + } + } +}); +</script> + +<style lang="scss" scoped> +.rsqzvsbo { + > .about { + overflow: hidden; + margin-bottom: var(--margin); + + > .banner { + height: 170px; + background-size: cover; + background-position: center center; + } + + > .body { + padding: 32px; + + @media (max-width: 500px) { + padding: 16px; + } + + > .name { + margin: 0 0 0.5em 0; + } + } + } +} +</style> diff --git a/src/client/pages/index.welcome.setup.vue b/src/client/pages/index.welcome.setup.vue new file mode 100644 index 0000000000..a339ac0a28 --- /dev/null +++ b/src/client/pages/index.welcome.setup.vue @@ -0,0 +1,102 @@ +<template> +<form class="mk-setup" @submit.prevent="submit()"> + <h1>Welcome to Misskey!</h1> + <div> + <p>{{ $t('intro') }}</p> + <mk-input v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" spellcheck="false" required> + <span>{{ $t('username') }}</span> + <template #prefix>@</template> + <template #suffix>@{{ host }}</template> + </mk-input> + <mk-input v-model="password" type="password"> + <span>{{ $t('password') }}</span> + <template #prefix><fa :icon="faLock"/></template> + </mk-input> + <footer> + <mk-button primary type="submit" :disabled="submitting">{{ submitting ? $t('processing') : $t('done') }}<mk-ellipsis v-if="submitting"/></mk-button> + </footer> + </div> +</form> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faLock } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '../components/ui/button.vue'; +import MkInput from '../components/ui/input.vue'; +import { host } from '../config'; +import i18n from '../i18n'; + +export default Vue.extend({ + i18n, + + components: { + MkButton, + MkInput, + }, + + data() { + return { + username: '', + password: '', + submitting: false, + host, + faLock + } + }, + + methods: { + submit() { + if (this.submitting) return; + this.submitting = true; + + this.$root.api('admin/accounts/create', { + username: this.username, + password: this.password, + }).then(res => { + localStorage.setItem('i', res.token); + location.href = '/'; + }).catch(() => { + this.submitting = false; + + this.$root.dialog({ + type: 'error', + text: this.$t('some-error') + }); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-setup { + border-radius: var(--radius); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); + overflow: hidden; + + > h1 { + margin: 0; + font-size: 1.5em; + text-align: center; + padding: 32px; + background: var(--accent); + color: #fff; + } + + > div { + padding: 32px; + background: var(--panel); + + > p { + margin-top: 0; + } + + > footer { + > * { + margin: 0 auto; + } + } + } +} +</style> diff --git a/src/client/pages/index.welcome.vue b/src/client/pages/index.welcome.vue new file mode 100644 index 0000000000..213c3db22c --- /dev/null +++ b/src/client/pages/index.welcome.vue @@ -0,0 +1,34 @@ +<template> +<div v-if="meta" class="mk-welcome"> + <portal to="title">{{ instanceName }}</portal> + <x-setup v-if="meta.requireSetup"/> + <x-entrance v-else/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XSetup from './index.welcome.setup.vue'; +import XEntrance from './index.welcome.entrance.vue'; +import { getInstanceName } from '../scripts/get-instance-name'; + +export default Vue.extend({ + components: { + XSetup, + XEntrance, + }, + + data() { + return { + meta: null, + instanceName: getInstanceName(), + } + }, + + created() { + this.$root.getMeta().then(meta => { + this.meta = meta; + }); + } +}); +</script> diff --git a/src/client/pages/instance/announcements.vue b/src/client/pages/instance/announcements.vue new file mode 100644 index 0000000000..71cec64c7b --- /dev/null +++ b/src/client/pages/instance/announcements.vue @@ -0,0 +1,129 @@ +<template> +<div class="ztgjmzrw"> + <portal to="icon"><fa :icon="faBroadcastTower"/></portal> + <portal to="title">{{ $t('announcements') }}</portal> + <mk-button @click="add()" primary style="margin: 0 auto 16px auto;"><fa :icon="faPlus"/> {{ $t('add') }}</mk-button> + <section class="_section announcements"> + <div class="_content announcement" v-for="announcement in announcements"> + <mk-input v-model="announcement.title" style="margin-top: 8px;"> + <span>{{ $t('title') }}</span> + </mk-input> + <mk-textarea v-model="announcement.text"> + <span>{{ $t('text') }}</span> + </mk-textarea> + <mk-input v-model="announcement.imageUrl"> + <span>{{ $t('imageUrl') }}</span> + </mk-input> + <p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p> + <div class="buttons"> + <mk-button class="button" inline @click="save(announcement)" primary><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <mk-button class="button" inline @click="remove(announcement)"><fa :icon="faTrashAlt"/> {{ $t('remove') }}</mk-button> + </div> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faBroadcastTower, faPlus } from '@fortawesome/free-solid-svg-icons'; +import { faSave, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../../i18n'; +import MkButton from '../../components/ui/button.vue'; +import MkInput from '../../components/ui/input.vue'; +import MkTextarea from '../../components/ui/textarea.vue'; + +export default Vue.extend({ + i18n, + + metaInfo() { + return { + title: this.$t('announcements') as string + }; + }, + + components: { + MkButton, + MkInput, + MkTextarea, + }, + + data() { + return { + announcements: [], + faBroadcastTower, faSave, faTrashAlt, faPlus + } + }, + + created() { + this.$root.api('admin/announcements/list').then(announcements => { + this.announcements = announcements; + }); + }, + + methods: { + add() { + this.announcements.unshift({ + id: null, + title: '', + text: '', + imageUrl: null + }); + }, + + remove(announcement) { + this.$root.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: announcement.title }), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + this.announcements = this.announcements.filter(x => x != announcement); + this.$root.api('admin/announcements/delete', announcement); + }); + }, + + save(announcement) { + if (announcement.id == null) { + this.$root.api('admin/announcements/create', announcement).then(() => { + this.$root.dialog({ + type: 'success', + text: this.$t('saved') + }); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + } else { + this.$root.api('admin/announcements/update', announcement).then(() => { + this.$root.dialog({ + type: 'success', + text: this.$t('saved') + }); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.ztgjmzrw { + > .announcements { + > .announcement { + > .buttons { + > .button:first-child { + margin-right: 8px; + } + } + } + } +} +</style> diff --git a/src/client/pages/instance/emojis.vue b/src/client/pages/instance/emojis.vue new file mode 100644 index 0000000000..7a69a7efe6 --- /dev/null +++ b/src/client/pages/instance/emojis.vue @@ -0,0 +1,253 @@ +<template> +<div class="mk-instance-emojis"> + <portal to="icon"><fa :icon="faLaugh"/></portal> + <portal to="title">{{ $t('customEmojis') }}</portal> + <section class="_section local"> + <div class="_title"><fa :icon="faLaugh"/> {{ $t('customEmojis') }}</div> + <div class="_content"> + <input ref="file" type="file" style="display: none;" @change="onChangeFile"/> + <mk-pagination :pagination="pagination" class="emojis" ref="emojis"> + <template #empty><span>{{ $t('noCustomEmojis') }}</span></template> + <template #default="{items}"> + <div class="emoji" v-for="(emoji, i) in items" :key="emoji.id" :data-index="i" @click="selected = emoji" :class="{ selected: selected && (selected.id === emoji.id) }"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <span class="name">{{ emoji.name }}</span> + </div> + </div> + </template> + </mk-pagination> + </div> + <div class="_footer"> + <mk-button inline primary @click="add()"><fa :icon="faPlus"/> {{ $t('addEmoji') }}</mk-button> + <mk-button inline :disabled="selected == null" @click="del()"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</mk-button> + </div> + </section> + <section class="_section remote"> + <div class="_title"><fa :icon="faLaugh"/> {{ $t('customEmojisOfRemote') }}</div> + <div class="_content"> + <mk-input v-model="host" :debounce="true" style="margin-top: 0;"><span>{{ $t('host') }}</span></mk-input> + <mk-pagination :pagination="remotePagination" class="emojis" ref="remoteEmojis"> + <template #empty><span>{{ $t('noCustomEmojis') }}</span></template> + <template #default="{items}"> + <div class="emoji" v-for="(emoji, i) in items" :key="emoji.id" :data-index="i" @click="selectedRemote = emoji" :class="{ selected: selectedRemote && (selectedRemote.id === emoji.id) }"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <span class="name">{{ emoji.name }}</span> + <span class="host">{{ emoji.host }}</span> + </div> + </div> + </template> + </mk-pagination> + </div> + <div class="_footer"> + <mk-button inline primary :disabled="selectedRemote == null" @click="im()"><fa :icon="faPlus"/> {{ $t('import') }}</mk-button> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faPlus } from '@fortawesome/free-solid-svg-icons'; +import { faTrashAlt, faLaugh } from '@fortawesome/free-regular-svg-icons'; +import MkButton from '../../components/ui/button.vue'; +import MkInput from '../../components/ui/input.vue'; +import MkPagination from '../../components/ui/pagination.vue'; +import { apiUrl } from '../../config'; + +export default Vue.extend({ + metaInfo() { + return { + title: `${this.$t('customEmojis')} | ${this.$t('instance')}` + }; + }, + + components: { + MkButton, + MkInput, + MkPagination, + }, + + data() { + return { + name: null, + selected: null, + selectedRemote: null, + host: '', + pagination: { + endpoint: 'admin/emoji/list', + limit: 10, + }, + remotePagination: { + endpoint: 'admin/emoji/list-remote', + limit: 10, + params: () => ({ + host: this.host ? this.host : null + }) + }, + faTrashAlt, faPlus, faLaugh + } + }, + + watch: { + host() { + this.$refs.remoteEmojis.reload(); + } + }, + + methods: { + async add() { + const { canceled: canceled, result: name } = await this.$root.dialog({ + title: this.$t('emojiName'), + input: true + }); + if (canceled) return; + + this.name = name; + + (this.$refs.file as any).click(); + }, + + onChangeFile() { + const [file] = Array.from((this.$refs.file as any).files); + if (file == null) return; + + const data = new FormData(); + data.append('file', file); + data.append('name', this.name); + data.append('i', this.$store.state.i.token); + + const dialog = this.$root.dialog({ + type: 'waiting', + text: this.$t('uploading') + '...', + showOkButton: false, + showCancelButton: false, + cancelableByBgClick: false + }); + + fetch(apiUrl + '/admin/emoji/add', { + method: 'POST', + body: data + }) + .then(response => response.json()) + .then(f => { + this.$refs.emojis.reload(); + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }) + .finally(() => { + dialog.close(); + }); + }, + + async del() { + const { canceled } = await this.$root.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: this.selected.name }), + showCancelButton: true + }); + if (canceled) return; + + this.$root.api('admin/emoji/remove', { + id: this.selected.id + }).then(() => { + this.$refs.emojis.reload(); + }); + }, + + im() { + this.$root.api('admin/emoji/copy', { + emojiId: this.selectedRemote.id, + }).then(() => { + this.$refs.emojis.reload(); + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-instance-emojis { + > .local { + > ._content { + max-height: 300px; + overflow: auto; + + > .emojis { + > .emoji { + display: flex; + align-items: center; + + &.selected { + background: var(--accent); + box-shadow: 0 0 0 8px var(--accent); + color: #fff; + } + + > .img { + width: 50px; + height: 50px; + } + + > .body { + padding: 8px; + + > .name { + display: block; + } + } + } + } + } + } + + > .remote { + > ._content { + max-height: 300px; + overflow: auto; + + > .emojis { + > .emoji { + display: flex; + align-items: center; + + &.selected { + background: var(--accent); + box-shadow: 0 0 0 8px var(--accent); + color: #fff; + } + + > .img { + width: 32px; + height: 32px; + } + + > .body { + padding: 0 8px; + + > .name { + display: block; + } + + > .host { + opacity: 0.5; + } + } + } + } + } + } +} +</style> diff --git a/src/client/pages/instance/federation.instance.vue b/src/client/pages/instance/federation.instance.vue new file mode 100644 index 0000000000..a27556064a --- /dev/null +++ b/src/client/pages/instance/federation.instance.vue @@ -0,0 +1,576 @@ +<template> +<x-window @closed="() => { $emit('closed'); destroyDom(); }" :no-padding="true"> + <template #header>{{ instance.host }}</template> + <div class="mk-instance-info"> + <div class="table info"> + <div class="row"> + <div class="cell"> + <div class="label">{{ $t('software') }}</div> + <div class="data">{{ instance.softwareName || '?' }}</div> + </div> + <div class="cell"> + <div class="label">{{ $t('version') }}</div> + <div class="data">{{ instance.softwareVersion || '?' }}</div> + </div> + </div> + </div> + <div class="table data"> + <div class="row"> + <div class="cell"> + <div class="label"><fa :icon="faCrosshairs" fixed-width class="icon"/>{{ $t('registeredAt') }}</div> + <div class="data">{{ new Date(instance.caughtAt).toLocaleString() }} (<mk-time :time="instance.caughtAt"/>)</div> + </div> + </div> + <div class="row"> + <div class="cell"> + <div class="label"><fa :icon="faCloudDownloadAlt" fixed-width class="icon"/>{{ $t('following') }}</div> + <div class="data clickable" @click="showFollowing()">{{ instance.followingCount | number }}</div> + </div> + <div class="cell"> + <div class="label"><fa :icon="faCloudUploadAlt" fixed-width class="icon"/>{{ $t('followers') }}</div> + <div class="data clickable" @click="showFollowers()">{{ instance.followersCount | number }}</div> + </div> + </div> + <div class="row"> + <div class="cell"> + <div class="label"><fa :icon="faUsers" fixed-width class="icon"/>{{ $t('users') }}</div> + <div class="data clickable" @click="showUsers()">{{ instance.usersCount | number }}</div> + </div> + <div class="cell"> + <div class="label"><fa :icon="faPencilAlt" fixed-width class="icon"/>{{ $t('notes') }}</div> + <div class="data">{{ instance.notesCount | number }}</div> + </div> + </div> + <div class="row"> + <div class="cell"> + <div class="label"><fa :icon="faFileImage" fixed-width class="icon"/>{{ $t('files') }}</div> + <div class="data">{{ instance.driveFiles | number }}</div> + </div> + <div class="cell"> + <div class="label"><fa :icon="faDatabase" fixed-width class="icon"/>{{ $t('storageUsage') }}</div> + <div class="data">{{ instance.driveUsage | bytes }}</div> + </div> + </div> + <div class="row"> + <div class="cell"> + <div class="label"><fa :icon="faLongArrowAltUp" fixed-width class="icon"/>{{ $t('latestRequestSentAt') }}</div> + <div class="data"><mk-time v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div> + </div> + <div class="cell"> + <div class="label"><fa :icon="faTrafficLight" fixed-width class="icon"/>{{ $t('latestStatus') }}</div> + <div class="data">{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</div> + </div> + </div> + <div class="row"> + <div class="cell"> + <div class="label"><fa :icon="faLongArrowAltDown" fixed-width class="icon"/>{{ $t('latestRequestReceivedAt') }}</div> + <div class="data"><mk-time v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div> + </div> + </div> + </div> + <div class="chart"> + <div class="header"> + <span class="label">{{ $t('charts') }}</span> + <div class="selects"> + <mk-select v-model="chartSrc" style="margin: 0; flex: 1;"> + <option value="requests">{{ $t('_instanceCharts.requests') }}</option> + <option value="users">{{ $t('_instanceCharts.users') }}</option> + <option value="users-total">{{ $t('_instanceCharts.usersTotal') }}</option> + <option value="notes">{{ $t('_instanceCharts.notes') }}</option> + <option value="notes-total">{{ $t('_instanceCharts.notesTotal') }}</option> + <option value="ff">{{ $t('_instanceCharts.ff') }}</option> + <option value="ff-total">{{ $t('_instanceCharts.ffTotal') }}</option> + <option value="drive-usage">{{ $t('_instanceCharts.cacheSize') }}</option> + <option value="drive-usage-total">{{ $t('_instanceCharts.cacheSizeTotal') }}</option> + <option value="drive-files">{{ $t('_instanceCharts.files') }}</option> + <option value="drive-files-total">{{ $t('_instanceCharts.filesTotal') }}</option> + </mk-select> + <mk-select v-model="chartSpan" style="margin: 0;"> + <option value="hour">{{ $t('perHour') }}</option> + <option value="day">{{ $t('perDay') }}</option> + </mk-select> + </div> + </div> + <div class="chart"> + <canvas ref="chart"></canvas> + </div> + </div> + <div class="operations"> + <span class="label">{{ $t('operations') }}</span> + <mk-switch v-model="isSuspended" class="switch">{{ $t('stopActivityDelivery') }}</mk-switch> + <mk-switch v-model="isBlocked" class="switch">{{ $t('blockThisInstance') }}</mk-switch> + </div> + <details class="metadata"> + <summary class="label">{{ $t('metadata') }}</summary> + <pre><code>{{ JSON.stringify(instance.metadata, null, 2) }}</code></pre> + </details> + </div> +</x-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Chart from 'chart.js'; +import i18n from '../../i18n'; +import { faTimes, faCrosshairs, faCloudDownloadAlt, faCloudUploadAlt, faUsers, faPencilAlt, faFileImage, faDatabase, faTrafficLight, faLongArrowAltUp, faLongArrowAltDown } from '@fortawesome/free-solid-svg-icons'; +import XWindow from '../../components/window.vue'; +import MkUsersDialog from '../../components/users-dialog.vue'; +import MkSelect from '../../components/ui/select.vue'; +import MkSwitch from '../../components/ui/switch.vue'; + +const chartLimit = 90; +const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); +const negate = arr => arr.map(x => -x); +const alpha = hex => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, 0.1)`; +}; + +export default Vue.extend({ + i18n, + + components: { + XWindow, + MkSelect, + MkSwitch, + }, + + props: { + instance: { + type: Object, + required: true + } + }, + + data() { + return { + meta: null, + isSuspended: false, + isBlocked: false, + now: null, + chart: null, + chartInstance: null, + chartSrc: 'requests', + chartSpan: 'hour', + faTimes, faCrosshairs, faCloudDownloadAlt, faCloudUploadAlt, faUsers, faPencilAlt, faFileImage, faDatabase, faTrafficLight, faLongArrowAltUp, faLongArrowAltDown + }; + }, + + computed: { + data(): any { + if (this.chart == null) return null; + switch (this.chartSrc) { + case 'requests': return this.requestsChart(); + case 'users': return this.usersChart(false); + case 'users-total': return this.usersChart(true); + case 'notes': return this.notesChart(false); + case 'notes-total': return this.notesChart(true); + case 'ff': return this.ffChart(false); + case 'ff-total': return this.ffChart(true); + case 'drive-usage': return this.driveUsageChart(false); + case 'drive-usage-total': return this.driveUsageChart(true); + case 'drive-files': return this.driveFilesChart(false); + case 'drive-files-total': return this.driveFilesChart(true); + } + }, + + stats(): any[] { + const stats = + this.chartSpan == 'day' ? this.chart.perDay : + this.chartSpan == 'hour' ? this.chart.perHour : + null; + + return stats; + } + }, + + watch: { + isSuspended() { + this.$root.api('admin/federation/update-instance', { + host: this.instance.host, + isSuspended: this.isSuspended + }); + }, + + isBlocked() { + this.$root.api('admin/update-meta', { + blockedHosts: this.isBlocked ? this.meta.blockedHosts.concat([this.instance.host]) : this.meta.blockedHosts.filter(x => x !== this.instance.host) + }); + }, + + chartSrc() { + this.renderChart(); + }, + + chartSpan() { + this.renderChart(); + } + }, + + async created() { + this.$root.getMeta().then(meta => { + this.meta = meta; + this.isSuspended = this.instance.isSuspended; + this.isBlocked = this.meta.blockedHosts.includes(this.instance.host); + }); + + this.now = new Date(); + + const [perHour, perDay] = await Promise.all([ + this.$root.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'hour' }), + this.$root.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'day' }), + ]); + + const chart = { + perHour: perHour, + perDay: perDay + }; + + this.chart = chart; + + this.renderChart(); + }, + + methods: { + setSrc(src) { + this.chartSrc = src; + }, + + renderChart() { + if (this.chartInstance) { + this.chartInstance.destroy(); + } + + Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + this.chartInstance = new Chart(this.$refs.chart, { + type: 'line', + data: { + labels: new Array(chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(), + datasets: this.data.series.map(x => ({ + label: x.name, + data: x.data.slice().reverse(), + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: x.color, + backgroundColor: alpha(x.color), + })) + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 0 + } + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + scales: { + xAxes: [{ + gridLines: { + display: false + }, + ticks: { + display: false + } + }], + yAxes: [{ + position: 'right', + ticks: { + display: false + } + }] + }, + tooltips: { + intersect: false, + mode: 'index', + } + } + }); + }, + + getDate(ago: number) { + const y = this.now.getFullYear(); + const m = this.now.getMonth(); + const d = this.now.getDate(); + const h = this.now.getHours(); + + return this.chartSpan == 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago); + }, + + format(arr) { + return arr; + }, + + requestsChart(): any { + return { + series: [{ + name: 'In', + color: '#008FFB', + data: this.format(this.stats.requests.received) + }, { + name: 'Out (succ)', + color: '#00E396', + data: this.format(this.stats.requests.succeeded) + }, { + name: 'Out (fail)', + color: '#FEB019', + data: this.format(this.stats.requests.failed) + }] + }; + }, + + usersChart(total: boolean): any { + return { + series: [{ + name: 'Users', + color: '#008FFB', + data: this.format(total + ? this.stats.users.total + : sum(this.stats.users.inc, negate(this.stats.users.dec)) + ) + }] + }; + }, + + notesChart(total: boolean): any { + return { + series: [{ + name: 'Notes', + color: '#008FFB', + data: this.format(total + ? this.stats.notes.total + : sum(this.stats.notes.inc, negate(this.stats.notes.dec)) + ) + }] + }; + }, + + ffChart(total: boolean): any { + return { + series: [{ + name: 'Following', + color: '#008FFB', + data: this.format(total + ? this.stats.following.total + : sum(this.stats.following.inc, negate(this.stats.following.dec)) + ) + }, { + name: 'Followers', + color: '#00E396', + data: this.format(total + ? this.stats.followers.total + : sum(this.stats.followers.inc, negate(this.stats.followers.dec)) + ) + }] + }; + }, + + driveUsageChart(total: boolean): any { + return { + bytes: true, + series: [{ + name: 'Drive usage', + color: '#008FFB', + data: this.format(total + ? this.stats.drive.totalUsage + : sum(this.stats.drive.incUsage, negate(this.stats.drive.decUsage)) + ) + }] + }; + }, + + driveFilesChart(total: boolean): any { + return { + series: [{ + name: 'Drive files', + color: '#008FFB', + data: this.format(total + ? this.stats.drive.totalFiles + : sum(this.stats.drive.incFiles, negate(this.stats.drive.decFiles)) + ) + }] + }; + }, + + showFollowing() { + this.$root.new(MkUsersDialog, { + title: this.$t('instanceFollowing'), + pagination: { + endpoint: 'federation/following', + limit: 10, + params: { + host: this.instance.host + } + }, + extract: item => item.follower + }); + }, + + showFollowers() { + this.$root.new(MkUsersDialog, { + title: this.$t('instanceFollowers'), + pagination: { + endpoint: 'federation/followers', + limit: 10, + params: { + host: this.instance.host + } + }, + extract: item => item.followee + }); + }, + + showUsers() { + this.$root.new(MkUsersDialog, { + title: this.$t('instanceUsers'), + pagination: { + endpoint: 'federation/users', + limit: 10, + params: { + host: this.instance.host + } + } + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-instance-info { + overflow: auto; + + > .table { + padding: 0 32px; + + @media (max-width: 500px) { + padding: 0 16px; + } + + > .row { + display: flex; + + &:not(:last-child) { + margin-bottom: 8px; + } + + > .cell { + flex: 1; + + > .label { + font-size: 80%; + opacity: 0.7; + + > .icon { + margin-right: 4px; + display: none; + } + } + + > .data.clickable { + color: var(--accent); + cursor: pointer; + } + } + } + } + + > .data { + margin-top: 16px; + padding-top: 16px; + border-top: solid 1px var(--divider); + + @media (max-width: 500px) { + margin-top: 8px; + padding-top: 8px; + } + } + + > .chart { + margin-top: 16px; + padding-top: 16px; + border-top: solid 1px var(--divider); + + @media (max-width: 500px) { + margin-top: 8px; + padding-top: 8px; + } + + > .header { + padding: 0 32px; + + @media (max-width: 500px) { + padding: 0 16px; + } + + > .label { + font-size: 80%; + opacity: 0.7; + } + + > .selects { + display: flex; + } + } + + > .chart { + padding: 0 16px; + + @media (max-width: 500px) { + padding: 0; + } + } + } + + > .operations { + padding: 16px 32px 16px 32px; + margin-top: 8px; + border-top: solid 1px var(--divider); + + @media (max-width: 500px) { + padding: 8px 16px 8px 16px; + margin-top: 0; + } + + > .label { + font-size: 80%; + opacity: 0.7; + } + + > .switch { + margin: 16px 0; + } + } + + > .metadata { + padding: 16px 32px 16px 32px; + border-top: solid 1px var(--divider); + + @media (max-width: 500px) { + padding: 8px 16px 8px 16px; + } + + > .label { + font-size: 80%; + opacity: 0.7; + } + + > pre > code { + display: block; + max-height: 200px; + overflow: auto; + } + } +} +</style> diff --git a/src/client/pages/instance/federation.vue b/src/client/pages/instance/federation.vue new file mode 100644 index 0000000000..224ff72a9f --- /dev/null +++ b/src/client/pages/instance/federation.vue @@ -0,0 +1,165 @@ +<template> +<div class="mk-federation"> + <section class="_section instances"> + <div class="_title"><fa :icon="faGlobe"/> {{ $t('instances') }}</div> + <div class="_content"> + <div class="inputs" style="display: flex;"> + <mk-input v-model="host" :debounce="true" style="margin: 0; flex: 1;"><span>{{ $t('host') }}</span></mk-input> + <mk-select v-model="state" style="margin: 0;"> + <option value="all">{{ $t('all') }}</option> + <option value="federating">{{ $t('federating') }}</option> + <option value="subscribing">{{ $t('subscribing') }}</option> + <option value="publishing">{{ $t('publishing') }}</option> + <option value="suspended">{{ $t('suspended') }}</option> + <option value="blocked">{{ $t('blocked') }}</option> + <option value="notResponding">{{ $t('notResponding') }}</option> + </mk-select> + </div> + </div> + <div class="_content"> + <mk-pagination :pagination="pagination" #default="{items}" class="instances" ref="instances" :key="host + state"> + <div class="instance" v-for="(instance, i) in items" :key="instance.id" :data-index="i" @click="info(instance)"> + <div class="host"><fa :icon="faCircle" class="indicator" :class="getStatus(instance)"/><b>{{ instance.host }}</b></div> + <div class="status"> + <span class="sub" v-if="instance.followersCount > 0"><fa :icon="faCaretDown" class="icon"/>Sub</span> + <span class="sub" v-else><fa :icon="faCaretDown" class="icon"/>-</span> + <span class="pub" v-if="instance.followingCount > 0"><fa :icon="faCaretUp" class="icon"/>Pub</span> + <span class="pub" v-else><fa :icon="faCaretUp" class="icon"/>-</span> + <span class="lastCommunicatedAt"><fa :icon="faExchangeAlt" class="icon"/><mk-time :time="instance.lastCommunicatedAt"/></span> + <span class="latestStatus"><fa :icon="faTrafficLight" class="icon"/>{{ instance.latestStatus || '-' }}</span> + </div> + </div> + </mk-pagination> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faGlobe, faCircle, faExchangeAlt, faCaretDown, faCaretUp, faTrafficLight } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../../i18n'; +import MkButton from '../../components/ui/button.vue'; +import MkInput from '../../components/ui/input.vue'; +import MkSelect from '../../components/ui/select.vue'; +import MkPagination from '../../components/ui/pagination.vue'; +import MkInstanceInfo from './federation.instance.vue'; + +export default Vue.extend({ + i18n, + + metaInfo() { + return { + title: this.$t('federation') as string + }; + }, + + components: { + MkButton, + MkInput, + MkSelect, + MkPagination, + }, + + data() { + return { + host: '', + state: 'federating', + sort: '+pubSub', + pagination: { + endpoint: 'federation/instances', + limit: 10, + offsetMode: true, + params: () => ({ + sort: this.sort, + host: this.host != '' ? this.host : null, + ...( + this.state === 'federating' ? { federating: true } : + this.state === 'subscribing' ? { subscribing: true } : + this.state === 'publishing' ? { publishing: true } : + this.state === 'suspended' ? { suspended: true } : + this.state === 'blocked' ? { blocked: true } : + this.state === 'notResponding' ? { notResponding: true } : + {}) + }) + }, + faGlobe, faCircle, faExchangeAlt, faCaretDown, faCaretUp, faTrafficLight + } + }, + + watch: { + host() { + this.$refs.instances.reload(); + }, + state() { + this.$refs.instances.reload(); + } + }, + + methods: { + getStatus(instance) { + if (instance.isSuspended) return 'off'; + if (instance.isNotResponding) return 'red'; + return 'green'; + }, + + info(instance) { + this.$root.new(MkInstanceInfo, { + instance: instance + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-federation { + > .instances { + > ._content { + > .instances { + > .instance { + cursor: pointer; + + > .host { + > .indicator { + font-size: 70%; + vertical-align: baseline; + margin-right: 4px; + + &.green { + color: #49c5ba; + } + + &.yellow { + color: #c5a549; + } + + &.red { + color: #c54949; + } + + &.off { + color: rgba(0, 0, 0, 0.5); + } + } + } + + > .status { + display: flex; + align-items: center; + font-size: 90%; + + > span { + flex: 1; + + > .icon { + margin-right: 6px; + } + } + } + } + } + } + } +} +</style> diff --git a/src/client/pages/instance/files.vue b/src/client/pages/instance/files.vue new file mode 100644 index 0000000000..e7475e94c1 --- /dev/null +++ b/src/client/pages/instance/files.vue @@ -0,0 +1,54 @@ +<template> +<section class="_section"> + <div class="_title"><fa :icon="faCloud"/> {{ $t('files') }}</div> + <div class="_content"> + <mk-button primary @click="clear()"><fa :icon="faTrashAlt"/> {{ $t('clearCachedFiles') }}</mk-button> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faCloud } from '@fortawesome/free-solid-svg-icons'; +import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import MkButton from '../../components/ui/button.vue'; +import MkPagination from '../../components/ui/pagination.vue'; + +export default Vue.extend({ + metaInfo() { + return { + title: `${this.$t('files')} | ${this.$t('instance')}` + }; + }, + + components: { + MkButton, + MkPagination, + }, + + data() { + return { + faTrashAlt, faCloud + } + }, + + methods: { + clear() { + this.$root.dialog({ + type: 'warning', + text: this.$t('clearCachedFilesConfirm'), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + this.$root.api('admin/drive/clean-remote-files', {}).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }); + }); + } + } +}); +</script> diff --git a/src/client/pages/instance/index.vue b/src/client/pages/instance/index.vue new file mode 100644 index 0000000000..5301fc7e01 --- /dev/null +++ b/src/client/pages/instance/index.vue @@ -0,0 +1,393 @@ +<template> +<div v-if="meta" class="mk-instance-page"> + <portal to="icon"><fa :icon="faServer"/></portal> + <portal to="title">{{ $t('instance') }}</portal> + + <section class="_section info"> + <div class="_title"><fa :icon="faInfoCircle"/> {{ $t('basicInfo') }}</div> + <div class="_content"> + <mk-input v-model="name" style="margin-top: 8px;">{{ $t('instanceName') }}</mk-input> + <mk-textarea v-model="description">{{ $t('instanceDescription') }}</mk-textarea> + <mk-input v-model="iconUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('iconUrl') }}</mk-input> + <mk-input v-model="bannerUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('bannerUrl') }}</mk-input> + <mk-input v-model="tosUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('tosUrl') }}</mk-input> + <mk-input v-model="maintainerName">{{ $t('maintainerName') }}</mk-input> + <mk-input v-model="maintainerEmail" type="email"><template #icon><fa :icon="faEnvelope"/></template>{{ $t('maintainerEmail') }}</mk-input> + </div> + <div class="_footer"> + <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + </div> + </section> + + <section class="_section info"> + <div class="_content"> + <mk-switch v-model="enableLocalTimeline" @change="save()">{{ $t('enableLocalTimeline') }}</mk-switch> + <mk-switch v-model="enableGlobalTimeline" @change="save()">{{ $t('enableGlobalTimeline') }}</mk-switch> + <mk-info>{{ $t('disablingTimelinesInfo') }}</mk-info> + </div> + </section> + + <section class="_section info"> + <div class="_title"><fa :icon="faUser"/> {{ $t('registration') }}</div> + <div class="_content"> + <mk-switch v-model="enableRegistration" @change="save()">{{ $t('enableRegistration') }}</mk-switch> + <mk-button v-if="!enableRegistration" @click="invite">{{ $t('invite') }}</mk-button> + </div> + </section> + + <section class="_section"> + <div class="_title"><fa :icon="faShieldAlt"/> {{ $t('recaptcha') }}</div> + <div class="_content"> + <mk-switch v-model="enableRecaptcha">{{ $t('enableRecaptcha') }}</mk-switch> + <template v-if="enableRecaptcha"> + <mk-info>{{ $t('recaptcha-info') }}</mk-info> + <mk-info warn>{{ $t('recaptcha-info2') }}</mk-info> + <mk-input v-model="recaptchaSiteKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSiteKey') }}</mk-input> + <mk-input v-model="recaptchaSecretKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSecretKey') }}</mk-input> + </template> + </div> + <div class="_content" v-if="enableRecaptcha && recaptchaSiteKey"> + <header>{{ $t('preview') }}</header> + <div ref="recaptcha" style="margin: 16px 0 0 0;" :key="recaptchaSiteKey"></div> + </div> + <div class="_footer"> + <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + </div> + </section> + + <section class="_section"> + <div class="_title"><fa :icon="faBolt"/> {{ $t('serviceworker') }}</div> + <div class="_content"> + <mk-switch v-model="enableServiceWorker">{{ $t('enableServiceworker') }}<template #desc>{{ $t('serviceworker-info') }}</template></mk-switch> + <template v-if="enableServiceWorker"> + <mk-info>{{ $t('vapid-info') }}<br><code>npm i web-push -g<br>web-push generate-vapid-keys</code></mk-info> + <mk-horizon-group inputs class="fit-bottom"> + <mk-input v-model="swPublicKey" :disabled="!enableServiceWorker"><template #icon><fa :icon="faKey"/></template>{{ $t('vapid-publickey') }}</mk-input> + <mk-input v-model="swPrivateKey" :disabled="!enableServiceWorker"><template #icon><fa :icon="faKey"/></template>{{ $t('vapid-privatekey') }}</mk-input> + </mk-horizon-group> + </template> + </div> + <div class="_footer"> + <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + </div> + </section> + + <section class="_section"> + <div class="_title"><fa :icon="faThumbtack"/> {{ $t('pinnedUsers') }}</div> + <div class="_content"> + <mk-textarea v-model="pinnedUsers" style="margin-top: 0;"> + <template #desc>{{ $t('pinnedUsersDescription') }} <button class="_textButton" @click="addPinUser">{{ $t('addUser') }}</button></template> + </mk-textarea> + </div> + <div class="_footer"> + <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + </div> + </section> + + <section class="_section"> + <div class="_title"><fa :icon="faCloud"/> {{ $t('files') }}</div> + <div class="_content"> + <mk-switch v-model="cacheRemoteFiles">{{ $t('cacheRemoteFiles') }}<template #desc>{{ $t('cacheRemoteFilesDescription') }}</template></mk-switch> + <mk-switch v-model="proxyRemoteFiles">{{ $t('proxyRemoteFiles') }}<template #desc>{{ $t('proxyRemoteFilesDescription') }}</template></mk-switch> + <mk-input v-model="localDriveCapacityMb" type="number">{{ $t('driveCapacityPerLocalAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></mk-input> + <mk-input v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" style="margin-bottom: 0;">{{ $t('driveCapacityPerRemoteAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></mk-input> + </div> + <div class="_footer"> + <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + </div> + </section> + + <section class="_section"> + <div class="_title"><fa :icon="faGhost"/> {{ $t('proxyAccount') }}</div> + <div class="_content"> + <mk-input v-model="proxyAccount" style="margin: 0;"><template #prefix>@</template>{{ $t('proxyAccount') }}<template #desc>{{ $t('proxyAccountDescription') }}</template></mk-input> + </div> + <div class="_footer"> + <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + </div> + </section> + + <section class="_section"> + <div class="_title"><fa :icon="faBan"/> {{ $t('blockedInstances') }}</div> + <div class="_content"> + <mk-textarea v-model="blockedHosts" style="margin-top: 0;"> + <template #desc>{{ $t('blockedInstancesDescription') }}</template> + </mk-textarea> + </div> + <div class="_footer"> + <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + </div> + </section> + + <section class="_section"> + <div class="_title"><fa :icon="faShareAlt"/> {{ $t('integration') }}</div> + <div class="_content"> + <header><fa :icon="faTwitter"/> {{ $t('twitter-integration-config') }}</header> + <mk-switch v-model="enableTwitterIntegration">{{ $t('enable-twitter-integration') }}</mk-switch> + <template v-if="enableTwitterIntegration"> + <mk-input v-model="twitterConsumerKey" :disabled="!enableTwitterIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('twitter-integration-consumer-key') }}</mk-input> + <mk-input v-model="twitterConsumerSecret" :disabled="!enableTwitterIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('twitter-integration-consumer-secret') }}</mk-input> + <mk-info>{{ $t('twitter-integration-info', { url: `${url}/api/tw/cb` }) }}</mk-info> + </template> + </div> + <div class="_content"> + <header><fa :icon="faGithub"/> {{ $t('github-integration-config') }}</header> + <mk-switch v-model="enableGithubIntegration">{{ $t('enable-github-integration') }}</mk-switch> + <template v-if="enableGithubIntegration"> + <mk-input v-model="githubClientId" :disabled="!enableGithubIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('github-integration-client-id') }}</mk-input> + <mk-input v-model="githubClientSecret" :disabled="!enableGithubIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('github-integration-client-secret') }}</mk-input> + <mk-info>{{ $t('github-integration-info', { url: `${url}/api/gh/cb` }) }}</mk-info> + </template> + </div> + <div class="_content"> + <header><fa :icon="faDiscord"/> {{ $t('discord-integration-config') }}</header> + <mk-switch v-model="enableDiscordIntegration">{{ $t('enable-discord-integration') }}</mk-switch> + <template v-if="enableDiscordIntegration"> + <mk-input v-model="discordClientId" :disabled="!enableDiscordIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('discord-integration-client-id') }}</mk-input> + <mk-input v-model="discordClientSecret" :disabled="!enableDiscordIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('discord-integration-client-secret') }}</mk-input> + <mk-info>{{ $t('discord-integration-info', { url: `${url}/api/dc/cb` }) }}</mk-info> + </template> + </div> + <div class="_footer"> + <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + </div> + </section> + + <section class="_section info"> + <div class="_title"><fa :icon="faInfoCircle"/> {{ $t('instanceInfo') }}</div> + <div class="_content table" v-if="stats"> + <div><b>{{ $t('users') }}</b><span>{{ stats.originalUsersCount | number }}</span></div> + <div><b>{{ $t('notes') }}</b><span>{{ stats.originalNotesCount | number }}</span></div> + </div> + <div class="_content table"> + <div><b>Misskey</b><span>v{{ version }}</span></div> + </div> + <div class="_content table" v-if="serverInfo"> + <div><b>Node.js</b><span>{{ serverInfo.node }}</span></div> + <div><b>PostgreSQL</b><span>v{{ serverInfo.psql }}</span></div> + <div><b>Redis</b><span>v{{ serverInfo.redis }}</span></div> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faShareAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faThumbtack, faUser, faShieldAlt, faKey, faBolt } from '@fortawesome/free-solid-svg-icons'; +import { faTrashAlt, faEnvelope } from '@fortawesome/free-regular-svg-icons'; +import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons'; +import MkButton from '../../components/ui/button.vue'; +import MkInput from '../../components/ui/input.vue'; +import MkTextarea from '../../components/ui/textarea.vue'; +import MkSwitch from '../../components/ui/switch.vue'; +import MkInfo from '../../components/ui/info.vue'; +import MkUserSelect from '../../components/user-select.vue'; +import { version } from '../../config'; +import i18n from '../../i18n'; +import getAcct from '../../../misc/acct/render'; + +export default Vue.extend({ + i18n, + + metaInfo() { + return { + title: this.$t('instance') as string + }; + }, + + components: { + MkButton, + MkInput, + MkTextarea, + MkSwitch, + MkInfo, + }, + + data() { + return { + version, + meta: null, + stats: null, + serverInfo: null, + proxyAccount: null, + cacheRemoteFiles: false, + proxyRemoteFiles: false, + localDriveCapacityMb: 0, + remoteDriveCapacityMb: 0, + blockedHosts: '', + pinnedUsers: '', + maintainerName: null, + maintainerEmail: null, + name: null, + description: null, + tosUrl: null, + bannerUrl: null, + iconUrl: null, + enableRegistration: false, + enableLocalTimeline: false, + enableGlobalTimeline: false, + enableRecaptcha: false, + recaptchaSiteKey: null, + recaptchaSecretKey: null, + enableServiceWorker: false, + swPublicKey: null, + swPrivateKey: null, + enableTwitterIntegration: false, + twitterConsumerKey: null, + twitterConsumerSecret: null, + enableGithubIntegration: false, + githubClientId: null, + githubClientSecret: null, + enableDiscordIntegration: false, + discordClientId: null, + discordClientSecret: null, + faTwitter, faDiscord, faGithub, faShareAlt, faTrashAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faEnvelope, faThumbtack, faUser, faShieldAlt, faKey, faBolt + } + }, + + created() { + this.$root.getMeta().then(meta => { + this.meta = meta; + this.name = this.meta.name; + this.description = this.meta.description; + this.tosUrl = this.meta.tosUrl; + this.bannerUrl = this.meta.bannerUrl; + this.iconUrl = this.meta.iconUrl; + this.maintainerName = this.meta.maintainerName; + this.maintainerEmail = this.meta.maintainerEmail; + this.enableRegistration = !this.meta.disableRegistration; + this.enableLocalTimeline = !this.meta.disableLocalTimeline; + this.enableGlobalTimeline = !this.meta.disableGlobalTimeline; + this.enableRecaptcha = this.meta.enableRecaptcha; + this.recaptchaSiteKey = this.meta.recaptchaSiteKey; + this.recaptchaSecretKey = this.meta.recaptchaSecretKey; + this.proxyAccount = this.meta.proxyAccount; + this.cacheRemoteFiles = this.meta.cacheRemoteFiles; + this.proxyRemoteFiles = this.meta.proxyRemoteFiles; + this.localDriveCapacityMb = this.meta.driveCapacityPerLocalUserMb; + this.remoteDriveCapacityMb = this.meta.driveCapacityPerRemoteUserMb; + this.blockedHosts = this.meta.blockedHosts.join('\n'); + this.pinnedUsers = this.meta.pinnedUsers.join('\n'); + this.enableServiceWorker = this.meta.enableServiceWorker; + this.swPublicKey = this.meta.swPublickey; + this.swPrivateKey = this.meta.swPrivateKey; + this.enableTwitterIntegration = this.meta.enableTwitterIntegration; + this.twitterConsumerKey = this.meta.twitterConsumerKey; + this.twitterConsumerSecret = this.meta.twitterConsumerSecret; + this.enableGithubIntegration = this.meta.enableGithubIntegration; + this.githubClientId = this.meta.githubClientId; + this.githubClientSecret = this.meta.githubClientSecret; + this.enableDiscordIntegration = this.meta.enableDiscordIntegration; + this.discordClientId = this.meta.discordClientId; + this.discordClientSecret = this.meta.discordClientSecret; + }); + + this.$root.api('admin/server-info').then(res => { + this.serverInfo = res; + }); + + this.$root.api('stats').then(res => { + this.stats = res; + }); + }, + + mounted() { + const renderRecaptchaPreview = () => { + if (!(window as any).grecaptcha) return; + if (!this.$refs.recaptcha) return; + if (!this.recaptchaSiteKey) return; + (window as any).grecaptcha.render(this.$refs.recaptcha, { + sitekey: this.recaptchaSiteKey + }); + }; + window.onRecaotchaLoad = () => { + renderRecaptchaPreview(); + }; + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?onload=onRecaotchaLoad'); + head.appendChild(script); + this.$watch('enableRecaptcha', () => { + renderRecaptchaPreview(); + }); + this.$watch('recaptchaSiteKey', () => { + renderRecaptchaPreview(); + }); + }, + + methods: { + addPinUser() { + this.$root.new(MkUserSelect, {}).$once('selected', user => { + this.pinnedUsers = this.pinnedUsers.trim(); + this.pinnedUsers += '\n@' + getAcct(user); + this.pinnedUsers = this.pinnedUsers.trim(); + }); + }, + + save(withDialog = false) { + this.$root.api('admin/update-meta', { + name: this.name, + description: this.description, + tosUrl: this.tosUrl, + bannerUrl: this.bannerUrl, + iconUrl: this.iconUrl, + maintainerName: this.maintainerName, + maintainerEmail: this.maintainerEmail, + disableRegistration: !this.enableRegistration, + disableLocalTimeline: !this.enableLocalTimeline, + disableGlobalTimeline: !this.enableGlobalTimeline, + enableRecaptcha: this.enableRecaptcha, + recaptchaSiteKey: this.recaptchaSiteKey, + recaptchaSecretKey: this.recaptchaSecretKey, + proxyAccount: this.proxyAccount, + cacheRemoteFiles: this.cacheRemoteFiles, + proxyRemoteFiles: this.proxyRemoteFiles, + localDriveCapacityMb: parseInt(this.localDriveCapacityMb, 10), + remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10), + blockedHosts: this.blockedHosts.split('\n') || [], + pinnedUsers: this.pinnedUsers ? this.pinnedUsers.split('\n') : [], + enableServiceWorker: this.enableServiceWorker, + swPublicKey: this.swPublicKey, + swPrivateKey: this.swPrivateKey, + enableTwitterIntegration: this.enableTwitterIntegration, + twitterConsumerKey: this.twitterConsumerKey, + twitterConsumerSecret: this.twitterConsumerSecret, + enableGithubIntegration: this.enableGithubIntegration, + githubClientId: this.githubClientId, + githubClientSecret: this.githubClientSecret, + enableDiscordIntegration: this.enableDiscordIntegration, + discordClientId: this.discordClientId, + discordClientSecret: this.discordClientSecret, + }).then(() => { + if (withDialog) { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + } + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-instance-page { + > .info { + > .table { + > div { + display: flex; + + > * { + flex: 1; + } + } + } + } +} +</style> diff --git a/src/client/pages/instance/monitor.vue b/src/client/pages/instance/monitor.vue new file mode 100644 index 0000000000..3f3ce6d73a --- /dev/null +++ b/src/client/pages/instance/monitor.vue @@ -0,0 +1,381 @@ +<template> +<div class="mk-instance-monitor"> + <section class="_section"> + <div class="_title"><fa :icon="faMicrochip"/> {{ $t('cpuAndMemory') }}</div> + <div class="_content" style="margin-top: -8px; margin-bottom: -12px;"> + <canvas ref="cpumem"></canvas> + </div> + <div class="_content" v-if="serverInfo"> + <div class="table"> + <div class="row"> + <div class="cell"><div class="label">CPU</div>{{ serverInfo.cpu.model }}</div> + </div> + <div class="row"> + <div class="cell"><div class="label">MEM total</div>{{ serverInfo.mem.total | bytes }}</div> + <div class="cell"><div class="label">MEM used</div>{{ memUsage | bytes }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div> + <div class="cell"><div class="label">MEM free</div>{{ serverInfo.mem.total - memUsage | bytes }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div> + </div> + </div> + </div> + </section> + <section class="_section"> + <div class="_title"><fa :icon="faHdd"/> {{ $t('disk') }}</div> + <div class="_content" style="margin-top: -8px; margin-bottom: -12px;"> + <canvas ref="disk"></canvas> + </div> + <div class="_content" v-if="serverInfo"> + <div class="table"> + <div class="row"> + <div class="cell"><div class="label">Disk total</div>{{ serverInfo.fs.total | bytes }}</div> + <div class="cell"><div class="label">Disk used</div>{{ serverInfo.fs.used | bytes }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div> + <div class="cell"><div class="label">Disk free</div>{{ serverInfo.fs.total - serverInfo.fs.used | bytes }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div> + </div> + </div> + </div> + </section> + <section class="_section"> + <div class="_title"><fa :icon="faExchangeAlt"/> {{ $t('network') }}</div> + <div class="_content" style="margin-top: -8px; margin-bottom: -12px;"> + <canvas ref="net"></canvas> + </div> + <div class="_content" v-if="serverInfo"> + <div class="table"> + <div class="row"> + <div class="cell"><div class="label">Interface</div>{{ serverInfo.net.interface }}</div> + </div> + </div> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faTachometerAlt, faExchangeAlt, faMicrochip, faHdd } from '@fortawesome/free-solid-svg-icons'; +import Chart from 'chart.js'; +import i18n from '../../i18n'; + +const alpha = (hex, a) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; + +export default Vue.extend({ + i18n, + + metaInfo() { + return { + title: `${this.$t('monitor')} | ${this.$t('instance')}` + }; + }, + + components: { + }, + + data() { + return { + connection: null, + serverInfo: null, + memUsage: 0, + chartCpuMem: null, + chartNet: null, + faTachometerAlt, faExchangeAlt, faMicrochip, faHdd + } + }, + + mounted() { + Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + this.chartCpuMem = new Chart(this.$refs.cpumem, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'CPU', + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: '#86b300', + backgroundColor: alpha('#86b300', 0.1), + data: [] + }, { + label: 'MEM (active)', + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: '#935dbf', + backgroundColor: alpha('#935dbf', 0.02), + data: [] + }, { + label: 'MEM (used)', + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: '#935dbf', + borderDash: [5, 5], + fill: false, + data: [] + }] + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 0, + right: 0, + top: 8, + bottom: 0 + } + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + scales: { + xAxes: [{ + gridLines: { + display: false + }, + ticks: { + display: false + } + }], + yAxes: [{ + position: 'right', + ticks: { + display: false, + max: 100 + } + }] + }, + tooltips: { + intersect: false, + mode: 'index', + } + } + }); + + this.chartNet = new Chart(this.$refs.net, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'In', + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: '#94a029', + backgroundColor: alpha('#94a029', 0.1), + data: [] + }, { + label: 'Out', + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: '#ff9156', + backgroundColor: alpha('#ff9156', 0.1), + data: [] + }] + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 0, + right: 0, + top: 8, + bottom: 0 + } + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + scales: { + xAxes: [{ + gridLines: { + display: false + }, + ticks: { + display: false + } + }], + yAxes: [{ + position: 'right', + ticks: { + display: false, + } + }] + }, + tooltips: { + intersect: false, + mode: 'index', + } + } + }); + + this.chartDisk = new Chart(this.$refs.disk, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Read', + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: '#94a029', + backgroundColor: alpha('#94a029', 0.1), + data: [] + }, { + label: 'Write', + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: '#ff9156', + backgroundColor: alpha('#ff9156', 0.1), + data: [] + }] + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 0, + right: 0, + top: 8, + bottom: 0 + } + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + scales: { + xAxes: [{ + gridLines: { + display: false + }, + ticks: { + display: false + } + }], + yAxes: [{ + position: 'right', + ticks: { + display: false, + } + }] + }, + tooltips: { + intersect: false, + mode: 'index', + } + } + }); + + this.$root.api('admin/server-info', {}).then(res => { + this.serverInfo = res; + + this.connection = this.$root.stream.useSharedConnection('serverStats'); + this.connection.on('stats', this.onStats); + this.connection.on('statsLog', this.onStatsLog); + this.connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 150 + }); + }); + }, + + beforeDestroy() { + this.connection.off('stats', this.onStats); + this.connection.off('statsLog', this.onStatsLog); + this.connection.dispose(); + }, + + methods: { + onStats(stats) { + const cpu = (stats.cpu * 100).toFixed(0); + const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0); + const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0); + this.memUsage = stats.mem.active; + + this.chartCpuMem.data.labels.push(''); + this.chartCpuMem.data.datasets[0].data.push(cpu); + this.chartCpuMem.data.datasets[1].data.push(memActive); + this.chartCpuMem.data.datasets[2].data.push(memUsed); + this.chartNet.data.labels.push(''); + this.chartNet.data.datasets[0].data.push(stats.net.rx); + this.chartNet.data.datasets[1].data.push(stats.net.tx); + this.chartDisk.data.labels.push(''); + this.chartDisk.data.datasets[0].data.push(stats.fs.r); + this.chartDisk.data.datasets[1].data.push(stats.fs.w); + if (this.chartCpuMem.data.datasets[0].data.length > 150) { + this.chartCpuMem.data.labels.shift(); + this.chartCpuMem.data.datasets[0].data.shift(); + this.chartCpuMem.data.datasets[1].data.shift(); + this.chartCpuMem.data.datasets[2].data.shift(); + this.chartNet.data.labels.shift(); + this.chartNet.data.datasets[0].data.shift(); + this.chartNet.data.datasets[1].data.shift(); + this.chartDisk.data.labels.shift(); + this.chartDisk.data.datasets[0].data.shift(); + this.chartDisk.data.datasets[1].data.shift(); + } + this.chartCpuMem.update(); + this.chartNet.update(); + this.chartDisk.update(); + }, + + onStatsLog(statsLog) { + for (const stats of statsLog.reverse()) { + this.onStats(stats); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-instance-monitor { + > section { + > ._content { + > .table { + > .row { + display: flex; + + &:not(:last-child) { + margin-bottom: 16px; + + @media (max-width: 500px) { + margin-bottom: 8px; + } + } + + > .cell { + flex: 1; + + > .label { + font-size: 80%; + opacity: 0.7; + + > .icon { + margin-right: 4px; + display: none; + } + } + } + } + } + } + } +} +</style> diff --git a/src/client/pages/instance/queue.queue.vue b/src/client/pages/instance/queue.queue.vue new file mode 100644 index 0000000000..cc542b176f --- /dev/null +++ b/src/client/pages/instance/queue.queue.vue @@ -0,0 +1,204 @@ +<template> +<section class="_section mk-queue-queue"> + <div class="_title"><slot name="title"></slot></div> + <div class="_content status"> + <div class="cell"><div class="label">Process</div>{{ activeSincePrevTick | number }}</div> + <div class="cell"><div class="label">Active</div>{{ active | number }}</div> + <div class="cell"><div class="label">Waiting</div>{{ waiting | number }}</div> + <div class="cell"><div class="label">Delayed</div>{{ delayed | number }}</div> + </div> + <div class="_content" style="margin-bottom: -8px;"> + <canvas ref="chart"></canvas> + </div> + <div class="_content" style="max-height: 180px; overflow: auto;"> + <sequential-entrance :delay="15" v-if="jobs.length > 0"> + <div v-for="(job, i) in jobs" :key="job[0]" :data-index="i"> + <span>{{ job[0] }}</span> + <span style="margin-left: 8px; opacity: 0.7;">({{ job[1] | number }} jobs)</span> + </div> + </sequential-entrance> + <span v-else style="opacity: 0.5;">{{ $t('noJobs') }}</span> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Chart from 'chart.js'; +import i18n from '../../i18n'; + +const alpha = (hex, a) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; + +export default Vue.extend({ + i18n, + + props: { + domain: { + required: true + }, + connection: { + required: true + }, + }, + + data() { + return { + chart: null, + jobs: [], + activeSincePrevTick: 0, + active: 0, + waiting: 0, + delayed: 0, + } + }, + + mounted() { + this.fetchJobs(); + + Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + this.chart = new Chart(this.$refs.chart, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Process', + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: '#00E396', + backgroundColor: alpha('#00E396', 0.1), + data: [] + }, { + label: 'Active', + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: '#00BCD4', + backgroundColor: alpha('#00BCD4', 0.1), + data: [] + }, { + label: 'Waiting', + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: '#FFB300', + backgroundColor: alpha('#FFB300', 0.1), + data: [] + }, { + label: 'Delayed', + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: '#E53935', + borderDash: [5, 5], + fill: false, + data: [] + }] + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 0, + right: 0, + top: 8, + bottom: 0 + } + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + scales: { + xAxes: [{ + gridLines: { + display: false + }, + ticks: { + display: false + } + }], + yAxes: [{ + position: 'right', + ticks: { + display: false, + } + }] + }, + tooltips: { + intersect: false, + mode: 'index', + } + } + }); + + this.connection.on('stats', this.onStats); + this.connection.on('statsLog', this.onStatsLog); + }, + + beforeDestroy() { + this.connection.off('stats', this.onStats); + this.connection.off('statsLog', this.onStatsLog); + }, + + methods: { + onStats(stats) { + this.activeSincePrevTick = stats[this.domain].activeSincePrevTick; + this.active = stats[this.domain].active; + this.waiting = stats[this.domain].waiting; + this.delayed = stats[this.domain].delayed; + this.chart.data.labels.push(''); + this.chart.data.datasets[0].data.push(stats[this.domain].activeSincePrevTick); + this.chart.data.datasets[1].data.push(stats[this.domain].active); + this.chart.data.datasets[2].data.push(stats[this.domain].waiting); + this.chart.data.datasets[3].data.push(stats[this.domain].delayed); + if (this.chart.data.datasets[0].data.length > 200) { + this.chart.data.labels.shift(); + this.chart.data.datasets[0].data.shift(); + this.chart.data.datasets[1].data.shift(); + this.chart.data.datasets[2].data.shift(); + this.chart.data.datasets[3].data.shift(); + } + this.chart.update(); + }, + + onStatsLog(statsLog) { + for (const stats of statsLog.reverse()) { + this.onStats(stats); + } + }, + + fetchJobs() { + this.$root.api(this.domain === 'inbox' ? 'admin/queue/inbox-delayed' : this.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(jobs => { + this.jobs = jobs; + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-queue-queue { + > .status { + display: flex; + + > .cell { + flex: 1; + + > .label { + font-size: 80%; + opacity: 0.7; + } + } + } +} +</style> diff --git a/src/client/pages/instance/queue.vue b/src/client/pages/instance/queue.vue new file mode 100644 index 0000000000..b7e633081f --- /dev/null +++ b/src/client/pages/instance/queue.vue @@ -0,0 +1,79 @@ +<template> +<div> + <x-queue :connection="connection" domain="inbox"> + <template #title><fa :icon="faExchangeAlt"/> In</template> + </x-queue> + <x-queue :connection="connection" domain="deliver"> + <template #title><fa :icon="faExchangeAlt"/> Out</template> + </x-queue> + <section class="_section"> + <div class="_content"> + <mk-button @click="clear()"><fa :icon="faTrashAlt"/> {{ $t('clearQueue') }}</mk-button> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faExchangeAlt } from '@fortawesome/free-solid-svg-icons'; +import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../../i18n'; +import MkButton from '../../components/ui/button.vue'; +import XQueue from './queue.queue.vue'; + +export default Vue.extend({ + i18n, + + metaInfo() { + return { + title: `${this.$t('jobQueue')} | ${this.$t('instance')}` + }; + }, + + components: { + MkButton, + XQueue, + }, + + data() { + return { + connection: this.$root.stream.useSharedConnection('queueStats'), + faExchangeAlt, faTrashAlt + } + }, + + mounted() { + this.$nextTick(() => { + this.connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 200 + }); + }); + }, + + beforeDestroy() { + this.connection.dispose(); + }, + + methods: { + clear() { + this.$root.dialog({ + type: 'warning', + title: this.$t('clearQueueConfirmTitle'), + text: this.$t('clearQueueConfirmText'), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + this.$root.api('admin/queue/clear', {}).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }); + }); + } + } +}); +</script> diff --git a/src/client/app/admin/views/dashboard.charts.vue b/src/client/pages/instance/stats.vue index b2ac19efff..595ad2cc3c 100644 --- a/src/client/app/admin/views/dashboard.charts.vue +++ b/src/client/pages/instance/stats.vue @@ -1,69 +1,89 @@ <template> -<div class="qvgidhudpqhjttdhxubzuyrhyzgslujw"> - <header> - <b><fa :icon="['far', 'chart-bar']"/> {{ $t('title') }}:</b> - <select v-model="src"> - <optgroup :label="$t('federation')"> - <option value="federation-instances">{{ $t('charts.federation-instances') }}</option> - <option value="federation-instances-total">{{ $t('charts.federation-instances-total') }}</option> - </optgroup> - <optgroup :label="$t('users')"> - <option value="users">{{ $t('charts.users') }}</option> - <option value="users-total">{{ $t('charts.users-total') }}</option> - <option value="active-users">{{ $t('charts.active-users') }}</option> - </optgroup> - <optgroup :label="$t('notes')"> - <option value="notes">{{ $t('charts.notes') }}</option> - <option value="local-notes">{{ $t('charts.local-notes') }}</option> - <option value="remote-notes">{{ $t('charts.remote-notes') }}</option> - <option value="notes-total">{{ $t('charts.notes-total') }}</option> - </optgroup> - <optgroup :label="$t('drive')"> - <option value="drive-files">{{ $t('charts.drive-files') }}</option> - <option value="drive-files-total">{{ $t('charts.drive-files-total') }}</option> - <option value="drive">{{ $t('charts.drive') }}</option> - <option value="drive-total">{{ $t('charts.drive-total') }}</option> - </optgroup> - <optgroup :label="$t('network')"> - <option value="network-requests">{{ $t('charts.network-requests') }}</option> - <option value="network-time">{{ $t('charts.network-time') }}</option> - <option value="network-usage">{{ $t('charts.network-usage') }}</option> - </optgroup> - </select> - <div> - <span @click="span = 'day'" :class="{ active: span == 'day' }">{{ $t('per-day') }}</span> | <span @click="span = 'hour'" :class="{ active: span == 'hour' }">{{ $t('per-hour') }}</span> +<div class="mk-instance-stats"> + <section class="_section"> + <div class="_title"><fa :icon="faChartBar"/> {{ $t('statistics') }}</div> + <div class="_content" style="margin-top: -8px; margin-bottom: -12px;"> + <div class="selects" style="display: flex;"> + <mk-select v-model="chartSrc" style="margin: 0; flex: 1;"> + <optgroup :label="$t('federation')"> + <option value="federation-instances">{{ $t('_charts.federationInstancesIncDec') }}</option> + <option value="federation-instances-total">{{ $t('_charts.federationInstancesTotal') }}</option> + </optgroup> + <optgroup :label="$t('users')"> + <option value="users">{{ $t('_charts.usersIncDec') }}</option> + <option value="users-total">{{ $t('_charts.usersTotal') }}</option> + <option value="active-users">{{ $t('_charts.activeUsers') }}</option> + </optgroup> + <optgroup :label="$t('notes')"> + <option value="notes">{{ $t('_charts.notesIncDec') }}</option> + <option value="local-notes">{{ $t('_charts.localNotesIncDec') }}</option> + <option value="remote-notes">{{ $t('_charts.remoteNotesIncDec') }}</option> + <option value="notes-total">{{ $t('_charts.notesTotal') }}</option> + </optgroup> + <optgroup :label="$t('drive')"> + <option value="drive-files">{{ $t('_charts.filesIncDec') }}</option> + <option value="drive-files-total">{{ $t('_charts.filesTotal') }}</option> + <option value="drive">{{ $t('_charts.storageUsageIncDec') }}</option> + <option value="drive-total">{{ $t('_charts.storageUsageTotal') }}</option> + </optgroup> + </mk-select> + <mk-select v-model="chartSpan" style="margin: 0;"> + <option value="hour">{{ $t('perHour') }}</option> + <option value="day">{{ $t('perDay') }}</option> + </mk-select> + </div> + <canvas ref="chart"></canvas> </div> - </header> - <div ref="chart"></div> + </section> </div> </template> <script lang="ts"> import Vue from 'vue'; +import { faChartBar } from '@fortawesome/free-solid-svg-icons'; +import Chart from 'chart.js'; import i18n from '../../i18n'; -import * as tinycolor from 'tinycolor2'; -import ApexCharts from 'apexcharts'; - -const limit = 90; +import MkSelect from '../../components/ui/select.vue'; +const chartLimit = 90; const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); const negate = arr => arr.map(x => -x); +const alpha = (hex, a) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; export default Vue.extend({ - i18n: i18n('admin/views/charts.vue'), + i18n, + + metaInfo() { + return { + title: `${this.$t('statistics')} | ${this.$t('instance')}` + }; + }, + + components: { + MkSelect + }, + data() { return { + now: null, chart: null, - src: 'notes', - span: 'hour', - chartInstance: null - }; + chartInstance: null, + chartSrc: 'notes', + chartSpan: 'hour', + faChartBar + } }, computed: { data(): any { if (this.chart == null) return null; - switch (this.src) { + switch (this.chartSrc) { case 'federation-instances': return this.federationInstancesChart(false); case 'federation-instances-total': return this.federationInstancesChart(true); case 'users': return this.usersChart(false); @@ -77,16 +97,13 @@ export default Vue.extend({ case 'drive-total': return this.driveTotalChart(); case 'drive-files': return this.driveFilesChart(); case 'drive-files-total': return this.driveFilesTotalChart(); - case 'network-requests': return this.networkRequestsChart(); - case 'network-time': return this.networkTimeChart(); - case 'network-usage': return this.networkUsageChart(); } }, stats(): any[] { const stats = - this.span == 'day' ? this.chart.perDay : - this.span == 'hour' ? this.chart.perHour : + this.chartSpan == 'day' ? this.chart.perDay : + this.chartSpan == 'hour' ? this.chart.perHour : null; return stats; @@ -94,32 +111,30 @@ export default Vue.extend({ }, watch: { - src() { - this.render(); + chartSrc() { + this.renderChart(); }, - span() { - this.render(); + chartSpan() { + this.renderChart(); } }, - async mounted() { + async created() { this.now = new Date(); const [perHour, perDay] = await Promise.all([Promise.all([ - this.$root.api('charts/federation', { limit: limit, span: 'hour' }), - this.$root.api('charts/users', { limit: limit, span: 'hour' }), - this.$root.api('charts/active-users', { limit: limit, span: 'hour' }), - this.$root.api('charts/notes', { limit: limit, span: 'hour' }), - this.$root.api('charts/drive', { limit: limit, span: 'hour' }), - this.$root.api('charts/network', { limit: limit, span: 'hour' }) + this.$root.api('charts/federation', { limit: chartLimit, span: 'hour' }), + this.$root.api('charts/users', { limit: chartLimit, span: 'hour' }), + this.$root.api('charts/active-users', { limit: chartLimit, span: 'hour' }), + this.$root.api('charts/notes', { limit: chartLimit, span: 'hour' }), + this.$root.api('charts/drive', { limit: chartLimit, span: 'hour' }), ]), Promise.all([ - this.$root.api('charts/federation', { limit: limit, span: 'day' }), - this.$root.api('charts/users', { limit: limit, span: 'day' }), - this.$root.api('charts/active-users', { limit: limit, span: 'day' }), - this.$root.api('charts/notes', { limit: limit, span: 'day' }), - this.$root.api('charts/drive', { limit: limit, span: 'day' }), - this.$root.api('charts/network', { limit: limit, span: 'day' }) + this.$root.api('charts/federation', { limit: chartLimit, span: 'day' }), + this.$root.api('charts/users', { limit: chartLimit, span: 'day' }), + this.$root.api('charts/active-users', { limit: chartLimit, span: 'day' }), + this.$root.api('charts/notes', { limit: chartLimit, span: 'day' }), + this.$root.api('charts/drive', { limit: chartLimit, span: 'day' }), ])]); const chart = { @@ -129,7 +144,6 @@ export default Vue.extend({ activeUsers: perHour[2], notes: perHour[3], drive: perHour[4], - network: perHour[5] }, perDay: { federation: perDay[0], @@ -137,115 +151,94 @@ export default Vue.extend({ activeUsers: perDay[2], notes: perDay[3], drive: perDay[4], - network: perDay[5] } }; this.chart = chart; - this.render(); - }, - - beforeDestroy() { - this.chartInstance.destroy(); + this.renderChart(); }, methods: { - setSrc(src) { - this.src = src; - }, - - render() { + renderChart() { if (this.chartInstance) { this.chartInstance.destroy(); } - this.chartInstance = new ApexCharts(this.$refs.chart, { - chart: { - type: 'area', - height: 300, - animations: { - dynamicAnimation: { - enabled: false - } - }, - toolbar: { - show: false - }, - zoom: { - enabled: false - } + Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + this.chartInstance = new Chart(this.$refs.chart, { + type: 'line', + data: { + labels: new Array(chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(), + datasets: this.data.series.map(x => ({ + label: x.name, + data: x.data.slice().reverse(), + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: x.color, + backgroundColor: alpha(x.color, 0.1), + hidden: !!x.hidden + })) }, - dataLabels: { - enabled: false - }, - grid: { - clipMarkers: false, - borderColor: 'rgba(0, 0, 0, 0.1)', - xaxis: { - lines: { - show: true, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 0, + top: 16, + bottom: 0 } }, - }, - stroke: { - curve: 'straight', - width: 2 - }, - legend: { - labels: { - colors: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString() - }, - }, - xaxis: { - type: 'datetime', - labels: { - style: { - colors: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString() + legend: { + position: 'bottom', + labels: { + boxWidth: 16, } }, - axisBorder: { - color: 'rgba(0, 0, 0, 0.1)' + scales: { + xAxes: [{ + gridLines: { + display: false + }, + ticks: { + display: false + } + }], + yAxes: [{ + position: 'right', + ticks: { + display: false + } + }] }, - axisTicks: { - color: 'rgba(0, 0, 0, 0.1)' - }, - }, - yaxis: { - labels: { - formatter: this.data.bytes ? v => Vue.filter('bytes')(v, 0) : v => Vue.filter('number')(v), - style: { - color: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString() - } + tooltips: { + intersect: false, + mode: 'index', } - }, - series: this.data.series + } }); - - this.chartInstance.render(); }, - getDate(i: number) { + getDate(ago: number) { const y = this.now.getFullYear(); const m = this.now.getMonth(); const d = this.now.getDate(); const h = this.now.getHours(); - return ( - this.span == 'day' ? new Date(y, m, d - i) : - this.span == 'hour' ? new Date(y, m, d, h - i) : - null - ); + return this.chartSpan == 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago); }, format(arr) { - return arr.map((v, i) => ({ x: this.getDate(i).getTime(), y: v })); + return arr; }, federationInstancesChart(total: boolean): any { return { series: [{ name: 'Instances', + color: '#008FFB', data: this.format(total ? this.stats.federation.instance.total : sum(this.stats.federation.instance.inc, negate(this.stats.federation.instance.dec)) @@ -259,6 +252,7 @@ export default Vue.extend({ series: [{ name: 'All', type: 'line', + color: '#008FFB', data: this.format(type == 'combined' ? sum(this.stats.notes.local.inc, negate(this.stats.notes.local.dec), this.stats.notes.remote.inc, negate(this.stats.notes.remote.dec)) : sum(this.stats.notes[type].inc, negate(this.stats.notes[type].dec)) @@ -266,6 +260,7 @@ export default Vue.extend({ }, { name: 'Renotes', type: 'area', + color: '#00E396', data: this.format(type == 'combined' ? sum(this.stats.notes.local.diffs.renote, this.stats.notes.remote.diffs.renote) : this.stats.notes[type].diffs.renote @@ -273,6 +268,7 @@ export default Vue.extend({ }, { name: 'Replies', type: 'area', + color: '#FEB019', data: this.format(type == 'combined' ? sum(this.stats.notes.local.diffs.reply, this.stats.notes.remote.diffs.reply) : this.stats.notes[type].diffs.reply @@ -280,6 +276,7 @@ export default Vue.extend({ }, { name: 'Normal', type: 'area', + color: '#FF4560', data: this.format(type == 'combined' ? sum(this.stats.notes.local.diffs.normal, this.stats.notes.remote.diffs.normal) : this.stats.notes[type].diffs.normal @@ -293,14 +290,19 @@ export default Vue.extend({ series: [{ name: 'Combined', type: 'line', + color: '#008FFB', data: this.format(sum(this.stats.notes.local.total, this.stats.notes.remote.total)) }, { name: 'Local', type: 'area', + color: '#008FFB', + hidden: true, data: this.format(this.stats.notes.local.total) }, { name: 'Remote', type: 'area', + color: '#008FFB', + hidden: true, data: this.format(this.stats.notes.remote.total) }] }; @@ -311,6 +313,7 @@ export default Vue.extend({ series: [{ name: 'Combined', type: 'line', + color: '#008FFB', data: this.format(total ? sum(this.stats.users.local.total, this.stats.users.remote.total) : sum(this.stats.users.local.inc, negate(this.stats.users.local.dec), this.stats.users.remote.inc, negate(this.stats.users.remote.dec)) @@ -318,6 +321,8 @@ export default Vue.extend({ }, { name: 'Local', type: 'area', + color: '#008FFB', + hidden: true, data: this.format(total ? this.stats.users.local.total : sum(this.stats.users.local.inc, negate(this.stats.users.local.dec)) @@ -325,6 +330,8 @@ export default Vue.extend({ }, { name: 'Remote', type: 'area', + color: '#008FFB', + hidden: true, data: this.format(total ? this.stats.users.remote.total : sum(this.stats.users.remote.inc, negate(this.stats.users.remote.dec)) @@ -338,14 +345,19 @@ export default Vue.extend({ series: [{ name: 'Combined', type: 'line', + color: '#008FFB', data: this.format(sum(this.stats.activeUsers.local.count, this.stats.activeUsers.remote.count)) }, { name: 'Local', type: 'area', + color: '#008FFB', + hidden: true, data: this.format(this.stats.activeUsers.local.count) }, { name: 'Remote', type: 'area', + color: '#008FFB', + hidden: true, data: this.format(this.stats.activeUsers.remote.count) }] }; @@ -357,6 +369,7 @@ export default Vue.extend({ series: [{ name: 'All', type: 'line', + color: '#008FFB', data: this.format( sum( this.stats.drive.local.incSize, @@ -368,18 +381,22 @@ export default Vue.extend({ }, { name: 'Local +', type: 'area', + color: '#008FFB', data: this.format(this.stats.drive.local.incSize) }, { name: 'Local -', type: 'area', + color: '#008FFB', data: this.format(negate(this.stats.drive.local.decSize)) }, { name: 'Remote +', type: 'area', + color: '#008FFB', data: this.format(this.stats.drive.remote.incSize) }, { name: 'Remote -', type: 'area', + color: '#008FFB', data: this.format(negate(this.stats.drive.remote.decSize)) }] }; @@ -391,14 +408,19 @@ export default Vue.extend({ series: [{ name: 'Combined', type: 'line', + color: '#008FFB', data: this.format(sum(this.stats.drive.local.totalSize, this.stats.drive.remote.totalSize)) }, { name: 'Local', type: 'area', + color: '#008FFB', + hidden: true, data: this.format(this.stats.drive.local.totalSize) }, { name: 'Remote', type: 'area', + color: '#008FFB', + hidden: true, data: this.format(this.stats.drive.remote.totalSize) }] }; @@ -409,6 +431,7 @@ export default Vue.extend({ series: [{ name: 'All', type: 'line', + color: '#008FFB', data: this.format( sum( this.stats.drive.local.incCount, @@ -420,18 +443,22 @@ export default Vue.extend({ }, { name: 'Local +', type: 'area', + color: '#008FFB', data: this.format(this.stats.drive.local.incCount) }, { name: 'Local -', type: 'area', + color: '#008FFB', data: this.format(negate(this.stats.drive.local.decCount)) }, { name: 'Remote +', type: 'area', + color: '#008FFB', data: this.format(this.stats.drive.remote.incCount) }, { name: 'Remote -', type: 'area', + color: '#008FFB', data: this.format(negate(this.stats.drive.remote.decCount)) }] }; @@ -442,86 +469,23 @@ export default Vue.extend({ series: [{ name: 'Combined', type: 'line', + color: '#008FFB', data: this.format(sum(this.stats.drive.local.totalCount, this.stats.drive.remote.totalCount)) }, { name: 'Local', type: 'area', + color: '#008FFB', + hidden: true, data: this.format(this.stats.drive.local.totalCount) }, { name: 'Remote', type: 'area', + color: '#008FFB', + hidden: true, data: this.format(this.stats.drive.remote.totalCount) }] }; }, - - networkRequestsChart(): any { - return { - series: [{ - name: 'Incoming', - data: this.format(this.stats.network.incomingRequests) - }] - }; - }, - - networkTimeChart(): any { - const data = []; - - for (let i = 0; i < limit; i++) { - data.push(this.stats.network.incomingRequests[i] != 0 ? (this.stats.network.totalTime[i] / this.stats.network.incomingRequests[i]) : 0); - } - - return { - series: [{ - name: 'Avg time', - data: this.format(data) - }] - }; - }, - - networkUsageChart(): any { - return { - bytes: true, - series: [{ - name: 'Incoming', - data: this.format(this.stats.network.incomingBytes) - }, { - name: 'Outgoing', - data: this.format(this.stats.network.outgoingBytes) - }] - }; - }, } }); </script> - -<style lang="stylus" scoped> -.qvgidhudpqhjttdhxubzuyrhyzgslujw - display block - flex 1 - padding 32px 24px - padding-bottom 0 - box-shadow 0 2px 4px rgba(0, 0, 0, 0.1) - background var(--face) - border-radius 8px - - > header - display flex - margin 0 8px - padding 0 0 8px 0 - font-size 1em - color var(--adminDashboardCardFg) - border-bottom solid 1px var(--adminDashboardCardDivider) - - > b - margin-right 8px - - > *:last-child - margin-left auto - - * - &:not(.active) - color var(--primary) - cursor pointer - -</style> diff --git a/src/client/pages/instance/users.vue b/src/client/pages/instance/users.vue new file mode 100644 index 0000000000..da59d8ce24 --- /dev/null +++ b/src/client/pages/instance/users.vue @@ -0,0 +1,203 @@ +<template> +<div class="mk-instance-users"> + <portal to="icon"><fa :icon="faUsers"/></portal> + <portal to="title">{{ $t('users') }}</portal> + + <section class="_section lookup"> + <div class="_title"><fa :icon="faSearch"/> {{ $t('lookup') }}</div> + <div class="_content"> + <mk-input class="target" v-model="target" type="text" @enter="showUser()" style="margin-top: 0;"> + <span>{{ $t('usernameOrUserId') }}</span> + </mk-input> + <mk-button @click="showUser()" primary><fa :icon="faSearch"/> {{ $t('lookup') }}</mk-button> + </div> + <div class="_footer"> + <mk-button inline primary @click="search()"><fa :icon="faSearch"/> {{ $t('search') }}</mk-button> + </div> + </section> + + <section class="_section users"> + <div class="_title"><fa :icon="faUsers"/> {{ $t('users') }}</div> + <div class="_content _list"> + <mk-pagination :pagination="pagination" #default="{items}" class="users" ref="users" :auto-margin="false"> + <button class="user _button _listItem" v-for="(user, i) in items" :key="user.id" :data-index="i" @click="show(user)"> + <mk-avatar :user="user" class="avatar"/> + <div class="body"> + <mk-user-name :user="user" class="name"/> + <mk-acct :user="user" class="acct"/> + </div> + </button> + </mk-pagination> + </div> + <div class="_footer"> + <mk-button inline primary @click="addUser()"><fa :icon="faPlus"/> {{ $t('addUser') }}</mk-button> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faPlus, faUsers, faSearch } from '@fortawesome/free-solid-svg-icons'; +import parseAcct from '../../../misc/acct/parse'; +import MkButton from '../../components/ui/button.vue'; +import MkInput from '../../components/ui/input.vue'; +import MkPagination from '../../components/ui/pagination.vue'; +import MkUserModerateDialog from '../../components/user-moderate-dialog.vue'; +import MkUserSelect from '../../components/user-select.vue'; + +export default Vue.extend({ + metaInfo() { + return { + title: `${this.$t('users')} | ${this.$t('instance')}` + }; + }, + + components: { + MkButton, + MkInput, + MkPagination, + }, + + data() { + return { + pagination: { + endpoint: 'admin/show-users', + limit: 10, + offsetMode: true + }, + target: '', + faPlus, faUsers, faSearch + } + }, + + methods: { + /** テキストエリアのユーザーを解決する */ + fetchUser() { + return new Promise((res) => { + const usernamePromise = this.$root.api('users/show', parseAcct(this.target)); + const idPromise = this.$root.api('users/show', { userId: this.target }); + let _notFound = false; + const notFound = () => { + if (_notFound) { + this.$root.dialog({ + type: 'error', + text: this.$t('noSuchUser') + }); + } else { + _notFound = true; + } + }; + usernamePromise.then(res).catch(e => { + if (e.code === 'NO_SUCH_USER') { + notFound(); + } + }); + idPromise.then(res).catch(e => { + notFound(); + }); + }); + }, + + /** テキストエリアから処理対象ユーザーを設定する */ + async showUser() { + const user = await this.fetchUser(); + this.$root.api('admin/show-user', { userId: user.id }).then(info => { + this.show(user, info); + }); + this.target = ''; + }, + + async addUser() { + const { canceled: canceled1, result: username } = await this.$root.dialog({ + title: this.$t('username'), + input: true + }); + if (canceled1) return; + + const { canceled: canceled2, result: password } = await this.$root.dialog({ + title: this.$t('password'), + input: { type: 'password' } + }); + if (canceled2) return; + + const dialog = this.$root.dialog({ + type: 'waiting', + iconOnly: true + }); + + this.$root.api('admin/accounts/create', { + username: username, + password: password, + }).then(res => { + this.$refs.users.reload(); + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e.id + }); + }).finally(() => { + dialog.close(); + }); + }, + + async show(user, info) { + if (info == null) info = await this.$root.api('admin/show-user', { userId: user.id }); + this.$root.new(MkUserModerateDialog, { + user: { ...user, ...info } + }); + }, + + search() { + this.$root.new(MkUserSelect, {}).$once('selected', user => { + this.$root.api('admin/show-user', { userId: user.id }).then(info => { + this.show(user, info); + }); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-instance-users { + > .users { + > ._content { + max-height: 300px; + overflow: auto; + + > .users { + > .user { + display: flex; + width: 100%; + box-sizing: border-box; + text-align: left; + align-items: center; + + > .avatar { + width: 50px; + height: 50px; + } + + > .body { + padding: 8px; + + > .name { + display: block; + font-weight: bold; + } + + > .acct { + opacity: 0.5; + } + } + } + } + } + } +} +</style> diff --git a/src/client/pages/mentions.vue b/src/client/pages/mentions.vue new file mode 100644 index 0000000000..333af91734 --- /dev/null +++ b/src/client/pages/mentions.vue @@ -0,0 +1,46 @@ +<template> +<div> + <portal to="icon"><fa :icon="faAt"/></portal> + <portal to="title">{{ $t('mentions') }}</portal> + <x-notes :pagination="pagination" :detail="true" @before="before()" @after="after()"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faAt } from '@fortawesome/free-solid-svg-icons'; +import Progress from '../scripts/loading'; +import XNotes from '../components/notes.vue'; + +export default Vue.extend({ + metaInfo() { + return { + title: this.$t('mentions') as string + }; + }, + + components: { + XNotes + }, + + data() { + return { + pagination: { + endpoint: 'notes/mentions', + limit: 10, + }, + faAt + }; + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/src/client/pages/messages.vue b/src/client/pages/messages.vue new file mode 100644 index 0000000000..1165004e97 --- /dev/null +++ b/src/client/pages/messages.vue @@ -0,0 +1,49 @@ +<template> +<div> + <portal to="icon"><fa :icon="faEnvelope"/></portal> + <portal to="title">{{ $t('directNotes') }}</portal> + <x-notes :pagination="pagination" :detail="true" @before="before()" @after="after()"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faEnvelope } from '@fortawesome/free-solid-svg-icons'; +import Progress from '../scripts/loading'; +import XNotes from '../components/notes.vue'; + +export default Vue.extend({ + metaInfo() { + return { + title: this.$t('directNotes') as string + }; + }, + + components: { + XNotes + }, + + data() { + return { + pagination: { + endpoint: 'notes/mentions', + limit: 10, + params: () => ({ + visibility: 'specified' + }) + }, + faEnvelope + }; + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/src/client/app/common/views/components/messaging-room.form.vue b/src/client/pages/messaging-room.form.vue index bd63bab2c1..4cdd2b1f32 100644 --- a/src/client/app/common/views/components/messaging-room.form.vue +++ b/src/client/pages/messaging-room.form.vue @@ -1,5 +1,5 @@ <template> -<div class="mk-messaging-form" +<div class="mk-messaging-form _panel" @dragover.stop="onDragover" @drop.stop="onDrop" > @@ -12,15 +12,15 @@ v-autocomplete="{ model: 'text' }" ></textarea> <div class="file" @click="file = null" v-if="file">{{ file.name }}</div> - <mk-uploader ref="uploader" @uploaded="onUploaded"/> - <button class="send" @click="send" :disabled="!canSend || sending" :title="$t('send')"> - <template v-if="!sending"><fa icon="paper-plane"/></template><template v-if="sending"><fa icon="spinner .spin"/></template> + <x-uploader ref="uploader" @uploaded="onUploaded"/> + <button class="send _button" @click="send" :disabled="!canSend || sending" :title="$t('send')"> + <template v-if="!sending"><fa :icon="faPaperPlane"/></template><template v-if="sending"><fa icon="spinner .spin"/></template> </button> - <button class="attach-from-local" @click="chooseFile" :title="$t('attach-from-local')"> - <fa icon="upload"/> + <button class="attach-from-local _button" @click="chooseFile" :title="$t('attach-from-local')"> + <fa :icon="faUpload"/> </button> - <button class="attach-from-drive" @click="chooseFileFromDrive" :title="$t('attach-from-drive')"> - <fa :icon="['far', 'folder-open']"/> + <button class="attach-from-drive _button" @click="chooseFileFromDrive" :title="$t('attach-from-drive')"> + <fa :icon="faCloud"/> </button> <input ref="file" type="file" @change="onChangeFile"/> </div> @@ -28,12 +28,16 @@ <script lang="ts"> import Vue from 'vue'; -import i18n from '../../../i18n'; +import { faPaperPlane, faUpload, faCloud } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; import * as autosize from 'autosize'; -import { formatTimeString } from '../../../../../misc/format-time-string'; +import { formatTimeString } from '../../misc/format-time-string'; export default Vue.extend({ - i18n: i18n('common/views/components/messaging-room.form.vue'), + i18n, + components: { + XUploader: () => import('../components/uploader.vue').then(m => m.default), + }, props: { user: { type: Object, @@ -48,7 +52,8 @@ export default Vue.extend({ return { text: null, file: null, - sending: false + sending: false, + faPaperPlane, faUpload, faCloud }; }, computed: { @@ -226,110 +231,127 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.mk-messaging-form - > textarea - cursor auto - display block - width 100% - min-width 100% - max-width 100% - height 64px - margin 0 - padding 8px - resize none - font-size 1em - color var(--inputText) - outline none - border none - border-top solid 1px var(--faceDivider) - border-radius 0 - box-shadow none - background transparent +<style lang="scss" scoped> +.mk-messaging-form { + position: relative; - > .file - padding 8px - color #444 - background #eee - cursor pointer + > textarea { + cursor: auto; + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + height: 80px; + margin: 0; + padding: 16px 16px 0 16px; + resize: none; + font-size: 1em; + outline: none; + border: none; + border-radius: 0; + box-shadow: none; + background: transparent; + box-sizing: border-box; + color: var(--fg); + } - > .send - position absolute - bottom 0 - right 0 - margin 0 - padding 10px 14px - font-size 1em - color #aaa - transition color 0.1s ease + > .file { + padding: 8px; + color: #444; + background: #eee; + cursor: pointer; + } - &:hover - color var(--primary) + > .send { + position: absolute; + bottom: 0; + right: 0; + margin: 0; + padding: 16px; + font-size: 1em; + color: #aaa; + transition: color 0.1s ease; - &:active - color var(--primaryDarken10) - transition color 0s ease + &:hover { + color: var(--accent); + } - .files - display block - margin 0 - padding 0 8px - list-style none + &:active { + color: var(--accentDarken); + transition: color 0s ease; + } + } - &:after - content '' - display block - clear both + .files { + display: block; + margin: 0; + padding: 0 8px; + list-style: none; - > li - display block - float left - margin 4px - padding 0 - width 64px - height 64px - background-color #eee - background-repeat no-repeat - background-position center center - background-size cover - cursor move + &:after { + content: ''; + display: block; + clear: both; + } - &:hover - > .remove - display block + > li { + display: block; + float: left; + margin: 4px; + padding: 0; + width: 64px; + height: 64px; + background-color: #eee; + background-repeat: no-repeat; + background-position: center center; + background-size: cover; + cursor: move; - > .remove - display none - position absolute - right -6px - top -6px - margin 0 - padding 0 - background transparent - outline none - border none - border-radius 0 - box-shadow none - cursor pointer + &:hover { + > .remove { + display: block; + } + } - .attach-from-local - .attach-from-drive - margin 0 - padding 10px 14px - font-size 1em - font-weight normal - text-decoration none - color #aaa - transition color 0.1s ease + > .remove { + display: none; + position: absolute; + right: -6px; + top: -6px; + margin: 0; + padding: 0; + background: transparent; + outline: none; + border: none; + border-radius: 0; + box-shadow: none; + cursor: pointer; + } + } + } - &:hover - color var(--primary) + .attach-from-local, + .attach-from-drive { + margin: 0; + padding: 16px; + font-size: 1em; + font-weight: normal; + text-decoration: none; + color: #aaa; + transition: color 0.1s ease; - &:active - color var(--primaryDarken10) - transition color 0s ease + &:hover { + color: var(--accent); + } - input[type=file] - display none + &:active { + color: var(--accentDarken); + transition: color 0s ease; + } + } + input[type=file] { + display: none; + } +} </style> diff --git a/src/client/pages/messaging-room.message.vue b/src/client/pages/messaging-room.message.vue new file mode 100644 index 0000000000..392eb6acb0 --- /dev/null +++ b/src/client/pages/messaging-room.message.vue @@ -0,0 +1,336 @@ +<template> +<div class="thvuemwp" :data-is-me="isMe"> + <mk-avatar class="avatar" :user="message.user"/> + <div class="content"> + <div class="balloon _panel" :data-no-text="message.text == null"> + <button class="delete-button" v-if="isMe" :title="$t('@.delete')" @click="del"> + <img src="/assets/desktop/remove.png" alt="Delete"/> + </button> + <div class="content" v-if="!message.isDeleted"> + <mfm class="text" v-if="message.text" ref="text" :text="message.text" :i="$store.state.i"/> + <div class="file" v-if="message.file"> + <a :href="message.file.url" rel="noopener" target="_blank" :title="message.file.name"> + <img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name" + :style="{ backgroundColor: message.file.properties.avgColor || 'transparent' }"/> + <p v-else>{{ message.file.name }}</p> + </a> + </div> + </div> + <div class="content" v-else> + <p class="is-deleted">{{ $t('deleted') }}</p> + </div> + </div> + <div></div> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + <footer> + <template v-if="isGroup"> + <span class="read" v-if="message.reads.length > 0">{{ $t('messageRead') }} {{ message.reads.length }}</span> + </template> + <template v-else> + <span class="read" v-if="isMe && message.isRead">{{ $t('messageRead') }}</span> + </template> + <mk-time :time="message.createdAt"/> + <template v-if="message.is_edited"><fa icon="pencil-alt"/></template> + </footer> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import { parse } from '../../mfm/parse'; +import { unique } from '../../prelude/array'; + +export default Vue.extend({ + i18n, + props: { + message: { + required: true + }, + isGroup: { + required: false + } + }, + computed: { + isMe(): boolean { + return this.message.userId == this.$store.state.i.id; + }, + urls(): string[] { + if (this.message.text) { + const ast = parse(this.message.text); + return unique(ast + .filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent)) + .map(t => t.node.props.url)); + } else { + return null; + } + } + }, + methods: { + del() { + this.$root.api('messaging/messages/delete', { + messageId: this.message.id + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.thvuemwp { + $me-balloon-color: var(--accent); + + position: relative; + background-color: transparent; + display: flex; + + > .avatar { + display: block; + width: 54px; + height: 54px; + transition: all 0.1s ease; + + @media (max-width: 400px) { + width: 48px; + height: 48px; + } + } + + > .content { + min-width: 0; + + > .balloon { + position: relative; + display: inline-flex; + align-items: center; + padding: 0; + min-height: 38px; + border-radius: 16px; + max-width: 100%; + + &:before { + content: ""; + pointer-events: none; + display: block; + position: absolute; + top: 12px; + } + + & + * { + clear: both; + } + + &:hover { + > .delete-button { + display: block; + } + } + + > .delete-button { + display: none; + position: absolute; + z-index: 1; + top: -4px; + right: -4px; + margin: 0; + padding: 0; + cursor: pointer; + outline: none; + border: none; + border-radius: 0; + box-shadow: none; + background: transparent; + + > img { + vertical-align: bottom; + width: 16px; + height: 16px; + cursor: pointer; + } + } + + > .content { + max-width: 100%; + + > .is-deleted { + display: block; + margin: 0; + padding: 0; + overflow: hidden; + overflow-wrap: break-word; + font-size: 1em; + color: rgba(#000, 0.5); + } + + > .text { + display: block; + margin: 0; + padding: 12px 18px; + overflow: hidden; + overflow-wrap: break-word; + word-break: break-word; + font-size: 1em; + color: rgba(#000, 0.8); + + @media (max-width: 500px) { + padding: 8px 16px; + } + + @media (max-width: 400px) { + font-size: 0.9em; + } + + & + .file { + > a { + border-radius: 0 0 16px 16px; + } + } + } + + > .file { + > a { + display: block; + max-width: 100%; + border-radius: 16px; + overflow: hidden; + text-decoration: none; + + &:hover { + text-decoration: none; + + > p { + background: #ccc; + } + } + + > * { + display: block; + margin: 0; + width: 100%; + max-height: 512px; + object-fit: contain; + } + + > p { + padding: 30px; + text-align: center; + color: #555; + background: #ddd; + } + } + } + } + } + + > .mk-url-preview { + margin: 8px 0; + } + + > footer { + display: block; + margin: 2px 0 0 0; + font-size: 10px; + color: var(--messagingRoomMessageInfo); + + > .read { + margin: 0 8px; + } + + > [data-icon] { + margin-left: 4px; + } + } + } + + &:not([data-is-me]) { + + > .content { + padding-left: 16px; + padding-right: 32px; + + > .balloon { + $color: var(--panel); + background: $color; + + &[data-no-text] { + background: transparent; + } + + &:not([data-no-text]):before { + left: -14px; + border-top: solid 8px transparent; + border-right: solid 8px $color; + border-bottom: solid 8px transparent; + border-left: solid 8px transparent; + } + + > .content { + > .text { + color: var(--fg); + } + } + } + + > footer { + text-align: left; + } + } + } + + &[data-is-me] { + flex-direction: row-reverse; + + > .content { + padding-right: 16px; + padding-left: 32px; + text-align: right; + + > .balloon { + background: $me-balloon-color; + text-align: left; + + &[data-no-text] { + background: transparent; + } + + &:not([data-no-text]):before { + right: -14px; + left: auto; + border-top: solid 8px transparent; + border-right: solid 8px transparent; + border-bottom: solid 8px transparent; + border-left: solid 8px $me-balloon-color; + } + + > .content { + + > p.is-deleted { + color: rgba(#fff, 0.5); + } + + > .text { + &, * { + color: #fff !important; + } + } + } + } + + > footer { + text-align: right; + + > .read { + user-select: none; + } + } + } + } + + &[data-is-deleted] { + > .balloon { + opacity: 0.5; + } + } +} +</style> diff --git a/src/client/app/common/views/components/messaging-room.vue b/src/client/pages/messaging-room.vue index d5fa4143a0..cba84b6de7 100644 --- a/src/client/app/common/views/components/messaging-room.vue +++ b/src/client/pages/messaging-room.vue @@ -3,65 +3,60 @@ @dragover.prevent.stop="onDragover" @drop.prevent.stop="onDrop" > + <template v-if="!fetching && user"> + <portal to="title"><mk-user-name :user="user" :nowrap="false" class="name"/></portal> + <portal to="avatar"><mk-avatar class="avatar" :user="user" :disable-preview="true"/></portal> + </template> + <template v-if="!fetching && group"> + <portal to="title">{{ group.name }}</portal> + </template> + <div class="body"> - <p class="init" v-if="init"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}</p> - <p class="empty" v-if="!init && messages.length == 0"><fa icon="info-circle"/>{{ user ? $t('not-talked-user') : $t('not-talked-group') }}</p> - <p class="no-history" v-if="!init && messages.length > 0 && !existMoreMessages"><fa :icon="faFlag"/>{{ $t('no-history') }}</p> - <button class="more" :class="{ fetching: fetchingMoreMessages }" v-if="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages"> + <mk-loading v-if="fetching"/> + <p class="empty" v-if="!fetching && messages.length == 0"><fa icon="info-circle"/>{{ user ? $t('not-talked-user') : $t('not-talked-group') }}</p> + <p class="no-history" v-if="!fetching && messages.length > 0 && !existMoreMessages"><fa :icon="faFlag"/>{{ $t('noMoreHistory') }}</p> + <button class="more _button" :class="{ fetching: fetchingMoreMessages }" v-if="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages"> <template v-if="fetchingMoreMessages"><fa icon="spinner" pulse fixed-width/></template>{{ fetchingMoreMessages ? $t('@.loading') : $t('@.load-more') }} </button> - <template v-for="(message, i) in _messages"> - <x-message :message="message" :key="message.id" :is-group="group != null"/> - <p class="date" v-if="i != messages.length - 1 && message._date != _messages[i + 1]._date"> - <span>{{ _messages[i + 1]._datetext }}</span> - </p> - </template> + <x-list class="messages" :items="messages" v-slot="{ item: message, i }" direction="up"> + <x-message :message="message" :is-group="group != null" :key="message.id" :data-index="messages.length - i"/> + </x-list> </div> <footer> <transition name="fade"> <div class="new-message" v-show="showIndicator"> - <button @click="onIndicatorClick"><i><fa :icon="faArrowCircleDown"/></i>{{ $t('new-message') }}</button> + <button class="_buttonPrimary" @click="onIndicatorClick"><i><fa :icon="faArrowCircleDown"/></i>{{ $t('new-message') }}</button> </div> </transition> - <x-form :user="user" :group="group" ref="form"/> + <x-form v-if="!fetching" :user="user" :group="group" ref="form"/> </footer> </div> </template> <script lang="ts"> import Vue from 'vue'; -import i18n from '../../../i18n'; +import { faArrowCircleDown, faFlag } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import XList from '../components/date-separated-list.vue'; import XMessage from './messaging-room.message.vue'; import XForm from './messaging-room.form.vue'; -import { url } from '../../../config'; -import { faArrowCircleDown, faFlag } from '@fortawesome/free-solid-svg-icons'; +import { url } from '../config'; +import parseAcct from '../../misc/acct/parse'; export default Vue.extend({ - i18n: i18n('common/views/components/messaging-room.vue'), + i18n, components: { XMessage, - XForm - }, - - props: { - user: { - type: Object, - requird: false, - }, - group: { - type: Object, - requird: false, - }, - isNaked: { - type: Boolean, - requird: false, - }, + XForm, + XList, }, data() { return { - init: true, + fetching: true, + user: null, + group: null, fetchingMoreMessages: false, messages: [], existMoreMessages: false, @@ -73,58 +68,57 @@ export default Vue.extend({ }, computed: { - _messages(): any[] { - return (this.messages as any).map(message => { - const date = new Date(message.createdAt).getDate(); - const month = new Date(message.createdAt).getMonth() + 1; - message._date = date; - message._datetext = this.$t('@.month-and-day').replace('{month}', month.toString()).replace('{day}', date.toString()); - return message; - }); - }, - form(): any { return this.$refs.form; } }, - mounted() { - this.connection = this.$root.stream.connectToChannel('messaging', { - otherparty: this.user ? this.user.id : undefined, - group: this.group ? this.group.id : undefined, - }); - - this.connection.on('message', this.onMessage); - this.connection.on('read', this.onRead); - this.connection.on('deleted', this.onDeleted); - - if (this.isNaked) { - window.addEventListener('scroll', this.onScroll, { passive: true }); - } else { - this.$el.addEventListener('scroll', this.onScroll, { passive: true }); - } - - document.addEventListener('visibilitychange', this.onVisibilitychange); + watch: { + $route: 'fetch' + }, - this.fetchMessages().then(() => { - this.init = false; - this.scrollToBottom(); - }); + mounted() { + this.fetch(); }, beforeDestroy() { this.connection.dispose(); - if (this.isNaked) { - window.removeEventListener('scroll', this.onScroll); - } else { - this.$el.removeEventListener('scroll', this.onScroll); - } + window.removeEventListener('scroll', this.onScroll); document.removeEventListener('visibilitychange', this.onVisibilitychange); }, methods: { + async fetch() { + this.fetching = true; + if (this.$route.params.user) { + const user = await this.$root.api('users/show', parseAcct(this.$route.params.user)); + this.user = user; + } else { + const group = await this.$root.api('users/groups/show', { groupId: this.$route.params.group }); + this.group = group; + } + + this.connection = this.$root.stream.connectToChannel('messaging', { + otherparty: this.user ? this.user.id : undefined, + group: this.group ? this.group.id : undefined, + }); + + this.connection.on('message', this.onMessage); + this.connection.on('read', this.onRead); + this.connection.on('deleted', this.onDeleted); + + window.addEventListener('scroll', this.onScroll, { passive: true }); + + document.addEventListener('visibilitychange', this.onVisibilitychange); + + this.fetchMessages().then(() => { + this.fetching = false; + this.scrollToBottom(); + }); + }, + onDragover(e) { const isFile = e.dataTransfer.items[0].kind == 'file'; const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; @@ -254,11 +248,7 @@ export default Vue.extend({ }, scrollToBottom() { - if (this.isNaked) { - window.scroll(0, document.body.offsetHeight); - } else { - this.$el.scrollTop = this.$el.scrollHeight; - } + window.scroll(0, document.body.offsetHeight); }, onIndicatorClick() { @@ -298,139 +288,108 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.mk-messaging-room - background var(--messagingRoomBg) - - > .body - width 100% - max-width 600px - margin 0 auto - min-height calc(100% - 103px) +<style lang="scss" scoped> +.mk-messaging-room { - > .init, - > .empty - width 100% - margin 0 - padding 16px 8px 8px 8px - text-align center - font-size 0.8em - color var(--messagingRoomInfo) - opacity 0.5 + > .body { + width: 100%; - [data-icon] - margin-right 4px + > .empty { + width: 100%; + margin: 0; + padding: 16px 8px 8px 8px; + text-align: center; + font-size: 0.8em; + opacity: 0.5; - > .no-history - display block - margin 0 - padding 16px - text-align center - font-size 0.8em - color var(--messagingRoomInfo) - opacity 0.5 - - [data-icon] - margin-right 4px - - > .more - display block - margin 16px auto - padding 0 12px - line-height 24px - color #fff - background rgba(#000, 0.3) - border-radius 12px - - &:hover - background rgba(#000, 0.4) - - &:active - background rgba(#000, 0.5) - - &.fetching - cursor wait + [data-icon] { + margin-right: 4px; + } + } - > [data-icon] - margin-right 4px + > .no-history { + display: block; + margin: 0; + padding: 16px; + text-align: center; + font-size: 0.8em; + color: var(--messagingRoomInfo); + opacity: 0.5; - > .message - // something + [data-icon] { + margin-right: 4px; + } + } - > .date - display block - margin 8px 0 - text-align center + > .more { + display: block; + margin: 16px auto; + padding: 0 12px; + line-height: 24px; + color: #fff; + background: rgba(#000, 0.3); + border-radius: 12px; - &:before - content '' - display block - position absolute - height 1px - width 90% - top 16px - left 0 - right 0 - margin 0 auto - background var(--messagingRoomDateDividerLine) + &:hover { + background: rgba(#000, 0.4); + } - > span - display inline-block - margin 0 - padding 0 16px - //font-weight bold - line-height 32px - color var(--messagingRoomDateDividerText) - background var(--messagingRoomBg) + &:active { + background: rgba(#000, 0.5); + } - > footer - position -webkit-sticky - position sticky - z-index 2 - bottom 0 - width 100% - max-width 600px - margin 0 auto - padding 0 - background var(--messagingRoomBg) - background-clip content-box + &.fetching { + cursor: wait; + } - > .new-message - position absolute - top -48px - width 100% - padding 8px 0 - text-align center + > [data-icon] { + margin-right: 4px; + } + } - > button - display inline-block - margin 0 - padding 0 12px 0 30px - cursor pointer - line-height 32px - font-size 12px - color var(--primaryForeground) - background var(--primary) - border-radius 16px + > .messages { + > ::v-deep * { + margin-bottom: 16px; + } + } + } - &:hover - background var(--primaryLighten10) + > footer { + width: 100%; - &:active - background var(--primaryDarken10) + > .new-message { + position: absolute; + top: -48px; + width: 100%; + padding: 8px 0; + text-align: center; - > i - position absolute - top 0 - left 10px - line-height 32px - font-size 16px + > button { + display: inline-block; + margin: 0; + padding: 0 12px 0 30px; + line-height: 32px; + font-size: 12px; + border-radius: 16px; -.fade-enter-active, .fade-leave-active - transition opacity 0.1s + > i { + position: absolute; + top: 0; + left: 10px; + line-height: 32px; + font-size: 16px; + } + } + } + } +} -.fade-enter, .fade-leave-to - transition opacity 0.5s - opacity 0 +.fade-enter-active, .fade-leave-active { + transition: opacity 0.1s; +} +.fade-enter, .fade-leave-to { + transition: opacity 0.5s; + opacity: 0; +} </style> diff --git a/src/client/pages/messaging.vue b/src/client/pages/messaging.vue new file mode 100644 index 0000000000..b94e01cad9 --- /dev/null +++ b/src/client/pages/messaging.vue @@ -0,0 +1,328 @@ +<template> +<div class="mk-messaging"> + <portal to="icon"><fa :icon="faComments"/></portal> + <portal to="title">{{ $t('messaging') }}</portal> + + <mk-button @click="start" primary class="start"><fa :icon="faPlus"/> {{ $t('startMessaging') }}</mk-button> + + <sequential-entrance class="history" v-if="messages.length > 0" :delay="30"> + <router-link v-for="(message, i) in messages" + class="message _panel" + :to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`" + :data-is-me="isMe(message)" + :data-is-read="message.groupId ? message.reads.includes($store.state.i.id) : message.isRead" + :data-index="i" + :key="message.id" + > + <div> + <mk-avatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user"/> + <header v-if="message.groupId"> + <span class="name">{{ message.group.name }}</span> + <mk-time :time="message.createdAt"/> + </header> + <header v-else> + <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> + <div class="body"> + <p class="text"><span class="me" v-if="isMe(message)">{{ $t('you') }}:</span>{{ message.text }}</p> + </div> + </div> + </router-link> + </sequential-entrance> + <p class="no-history" v-if="!fetching && messages.length == 0">{{ $t('no-history') }}</p> + <p class="fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faUser, faUsers, faComments, faPlus } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import getAcct from '../../misc/acct/render'; +import MkButton from '../components/ui/button.vue'; +import MkUserSelect from '../components/user-select.vue'; + +export default Vue.extend({ + i18n, + + components: { + MkButton + }, + + data() { + return { + fetching: true, + moreFetching: false, + messages: [], + connection: null, + faUser, faUsers, faComments, faPlus + }; + }, + + mounted() { + this.connection = this.$root.stream.useSharedConnection('messagingIndex'); + + this.connection.on('message', this.onMessage); + this.connection.on('read', this.onRead); + + this.$root.api('messaging/history', { group: false }).then(userMessages => { + this.$root.api('messaging/history', { group: true }).then(groupMessages => { + const messages = userMessages.concat(groupMessages); + messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + this.messages = messages; + this.fetching = false; + }); + }); + }, + + beforeDestroy() { + this.connection.dispose(); + }, + + methods: { + getAcct, + + isMe(message) { + return message.userId == this.$store.state.i.id; + }, + + onMessage(message) { + if (message.recipientId) { + this.messages = this.messages.filter(m => !( + (m.recipientId == message.recipientId && m.userId == message.userId) || + (m.recipientId == message.userId && m.userId == message.recipientId))); + + this.messages.unshift(message); + } else if (message.groupId) { + this.messages = this.messages.filter(m => m.groupId !== message.groupId); + this.messages.unshift(message); + } + }, + + onRead(ids) { + for (const id of ids) { + const found = this.messages.find(m => m.id == id); + if (found) { + if (found.recipientId) { + found.isRead = true; + } else if (found.groupId) { + found.reads.push(this.$store.state.i.id); + } + } + } + }, + + start(ev) { + this.$root.menu({ + items: [{ + text: this.$t('withUser'), + action: () => { this.startUser() } + }, { + text: this.$t('withGroup'), + action: () => { this.startGroup() } + }], + noCenter: true, + source: ev.currentTarget || ev.target, + }); + }, + + async startUser() { + this.$root.new(MkUserSelect, {}).$once('selected', user => { + this.$router.push(`/my/messaging/${getAcct(user)}`); + }); + }, + + async startGroup() { + const groups1 = await this.$root.api('users/groups/owned'); + const groups2 = await this.$root.api('users/groups/joined'); + const { canceled, result: group } = await this.$root.dialog({ + type: null, + title: this.$t('select-group'), + select: { + items: groups1.concat(groups2).map(group => ({ + value: group, text: group.name + })) + }, + showCancelButton: true + }); + if (canceled) return; + this.navigateGroup(group); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-messaging { + + > .start { + margin: 0 auto 16px auto; + } + + > .history { + > .message { + display: block; + text-decoration: none; + margin-bottom: 16px; + + @media (max-width: 500px) { + margin-bottom: 8px; + } + + * { + pointer-events: none; + user-select: none; + } + + &:hover { + .avatar { + filter: saturate(200%); + } + } + + &:active { + } + + &[data-is-read], + &[data-is-me] { + opacity: 0.8; + } + + &:not([data-is-me]):not([data-is-read]) { + > div { + background-image: url("/assets/unread.svg"); + background-repeat: no-repeat; + background-position: 0 center; + } + } + + &:after { + content: ""; + display: block; + clear: both; + } + + > div { + padding: 20px 30px; + + &:after { + content: ""; + display: block; + clear: both; + } + + > header { + display: flex; + align-items: center; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + + > .name { + margin: 0; + padding: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 1em; + font-weight: bold; + transition: all 0.1s ease; + } + + > .username { + margin: 0 8px; + } + + > .mk-time { + margin: 0 0 0 auto; + } + } + + > .avatar { + float: left; + width: 54px; + height: 54px; + margin: 0 16px 0 0; + border-radius: 8px; + transition: all 0.1s ease; + } + + > .body { + + > .text { + display: block; + margin: 0 0 0 0; + padding: 0; + overflow: hidden; + overflow-wrap: break-word; + font-size: 1.1em; + color: var(--faceText); + + .me { + opacity: 0.7; + } + } + + > .image { + display: block; + max-width: 100%; + max-height: 512px; + } + } + } + } + } + + > .no-history { + margin: 0; + padding: 2em 1em; + text-align: center; + color: #999; + font-weight: 500; + } + + > .fetching { + margin: 0; + padding: 16px; + text-align: center; + color: var(--text); + + > [data-icon] { + margin-right: 4px; + } + } + + @media (max-width: 400px) { + > .search { + > .result { + > .users { + > li { + padding: 8px 16px; + } + } + } + } + + > .history { + > .message { + &:not([data-is-me]):not([data-is-read]) { + > div { + background-image: none; + border-left: solid 4px #3aa2dc; + } + } + + > div { + padding: 16px; + font-size: 14px; + + > .avatar { + margin: 0 12px 0 0; + } + } + } + } + } +} +</style> diff --git a/src/client/pages/my-antennas/index.antenna.vue b/src/client/pages/my-antennas/index.antenna.vue new file mode 100644 index 0000000000..a4b140db1e --- /dev/null +++ b/src/client/pages/my-antennas/index.antenna.vue @@ -0,0 +1,174 @@ +<template> +<div class="shaynizk _section"> + <div class="_title" v-if="antenna.name">{{ antenna.name }}</div> + <div class="_content body"> + <mk-input v-model="name" style="margin-top: 8px;"> + <span>{{ $t('name') }}</span> + </mk-input> + <mk-select v-model="src"> + <template #label>{{ $t('antennaSource') }}</template> + <option value="all">{{ $t('_antennaSources.all') }}</option> + <option value="home">{{ $t('_antennaSources.homeTimeline') }}</option> + <option value="users">{{ $t('_antennaSources.users') }}</option> + <option value="list">{{ $t('_antennaSources.userList') }}</option> + </mk-select> + <mk-select v-model="userListId" v-if="src === 'list'"> + <template #label>{{ $t('userList') }}</template> + <option v-for="list in userLists" :value="list.id" :key="list.id">{{ list.name }}</option> + </mk-select> + <mk-textarea v-model="users" v-if="src === 'users'"> + <span>{{ $t('users') }}</span> + <template #desc>{{ $t('antennaUsersDescription') }} <button class="_textButton" @click="addUser">{{ $t('addUser') }}</button></template> + </mk-textarea> + <mk-switch v-model="withReplies">{{ $t('withReplies') }}</mk-switch> + <mk-textarea v-model="keywords"> + <span>{{ $t('antennaKeywords') }}</span> + <template #desc>{{ $t('antennaKeywordsDescription') }}</template> + </mk-textarea> + <mk-switch v-model="caseSensitive">{{ $t('caseSensitive') }}</mk-switch> + <mk-switch v-model="withFile">{{ $t('withFileAntenna') }}</mk-switch> + <mk-switch v-model="notify">{{ $t('notifyAntenna') }}</mk-switch> + </div> + <div class="_footer"> + <mk-button inline @click="saveAntenna()" primary><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <mk-button inline @click="deleteAntenna()" v-if="antenna.id != null"><fa :icon="faTrash"/> {{ $t('delete') }}</mk-button> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faSave, faTrash } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../../i18n'; +import MkButton from '../../components/ui/button.vue'; +import MkInput from '../../components/ui/input.vue'; +import MkTextarea from '../../components/ui/textarea.vue'; +import MkSelect from '../../components/ui/select.vue'; +import MkSwitch from '../../components/ui/switch.vue'; +import MkUserSelect from '../../components/user-select.vue'; +import getAcct from '../../../misc/acct/render'; + +export default Vue.extend({ + i18n, + + components: { + MkButton, MkInput, MkTextarea, MkSelect, MkSwitch + }, + + props: { + antenna: { + type: Object, + required: true + } + }, + + data() { + return { + name: '', + src: '', + userListId: null, + users: '', + keywords: '', + caseSensitive: false, + withReplies: false, + withFile: false, + notify: false, + userLists: null, + faSave, faTrash + }; + }, + + watch: { + async src() { + if (this.src === 'list' && this.userLists === null) { + this.userLists = await this.$root.api('users/lists/list'); + } + } + }, + + created() { + this.name = this.antenna.name; + this.src = this.antenna.src; + this.userListId = this.antenna.userListId; + this.users = this.antenna.users.join('\n'); + this.keywords = this.antenna.keywords.map(x => x.join(' ')).join('\n'); + this.caseSensitive = this.antenna.caseSensitive; + this.withReplies = this.antenna.withReplies; + this.withFile = this.antenna.withFile; + this.notify = this.antenna.notify; + }, + + methods: { + async saveAntenna() { + if (this.antenna.id == null) { + await this.$root.api('antennas/create', { + name: this.name, + src: this.src, + userListId: this.userListId, + withReplies: this.withReplies, + withFile: this.withFile, + notify: this.notify, + caseSensitive: this.caseSensitive, + users: this.users.trim().split('\n').map(x => x.trim()), + keywords: this.keywords.trim().split('\n').map(x => x.trim().split(' ')) + }); + this.$emit('created'); + } else { + await this.$root.api('antennas/update', { + antennaId: this.antenna.id, + name: this.name, + src: this.src, + userListId: this.userListId, + withReplies: this.withReplies, + withFile: this.withFile, + notify: this.notify, + caseSensitive: this.caseSensitive, + users: this.users.trim().split('\n').map(x => x.trim()), + keywords: this.keywords.trim().split('\n').map(x => x.trim().split(' ')) + }); + } + + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }, + + async deleteAntenna() { + const { canceled } = await this.$root.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: this.antenna.name }), + showCancelButton: true + }); + if (canceled) return; + + await this.$root.api('antennas/delete', { + antennaId: this.antenna.id, + }); + + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + this.$emit('deleted'); + }, + + addUser() { + this.$root.new(MkUserSelect, {}).$once('selected', user => { + this.users = this.users.trim(); + this.users += '\n@' + getAcct(user); + this.users = this.users.trim(); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.shaynizk { + > .body { + max-height: 250px; + overflow: auto; + } +} +</style> diff --git a/src/client/pages/my-antennas/index.vue b/src/client/pages/my-antennas/index.vue new file mode 100644 index 0000000000..3a9a11b541 --- /dev/null +++ b/src/client/pages/my-antennas/index.vue @@ -0,0 +1,80 @@ +<template> +<div class="ieepwinx"> + <portal to="icon"><fa :icon="faSatellite"/></portal> + <portal to="title">{{ $t('manageAntennas') }}</portal> + + <mk-button @click="create" primary class="add"><fa :icon="faPlus"/> {{ $t('createAntenna') }}</mk-button> + + <x-antenna v-if="draft" :antenna="draft" @created="onAntennaCreated" style="margin-bottom: var(--margin);"/> + + <mk-pagination :pagination="pagination" #default="{items}" class="antennas" ref="list"> + <x-antenna v-for="(antenna, i) in items" :key="antenna.id" :data-index="i" :antenna="antenna" @created="onAntennaDeleted"/> + </mk-pagination> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faSatellite, faPlus } from '@fortawesome/free-solid-svg-icons'; +import MkPagination from '../../components/ui/pagination.vue'; +import MkButton from '../../components/ui/button.vue'; +import XAntenna from './index.antenna.vue'; + +export default Vue.extend({ + metaInfo() { + return { + title: this.$t('manageAntennas') as string, + }; + }, + + components: { + MkPagination, + MkButton, + XAntenna, + }, + + data() { + return { + pagination: { + endpoint: 'antennas/list', + limit: 10, + }, + draft: null, + faSatellite, faPlus + }; + }, + + methods: { + create() { + this.draft = { + name: '', + src: 'all', + userListId: null, + users: [], + keywords: [], + withReplies: false, + caseSensitive: false, + withFile: false, + notify: false + }; + }, + + onAntennaCreated() { + this.$refs.list.reload(); + this.draft = null; + }, + + onAntennaDeleted() { + this.$refs.list.reload(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.ieepwinx { + > .add { + margin: 0 auto 16px auto; + } +} +</style> diff --git a/src/client/pages/my-lists/index.vue b/src/client/pages/my-lists/index.vue new file mode 100644 index 0000000000..6c4b46e85c --- /dev/null +++ b/src/client/pages/my-lists/index.vue @@ -0,0 +1,75 @@ +<template> +<div class="qkcjvfiv"> + <portal to="icon"><fa :icon="faListUl"/></portal> + <portal to="title">{{ $t('manageLists') }}</portal> + + <mk-button @click="create" primary class="add"><fa :icon="faPlus"/> {{ $t('createList') }}</mk-button> + + <mk-pagination :pagination="pagination" #default="{items}" class="lists" ref="list"> + <div class="list _panel" v-for="(list, i) in items" :key="list.id" :data-index="i"> + <router-link :to="`/lists/${ list.id }`">{{ list.name }}</router-link> + </div> + </mk-pagination> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faListUl, faPlus } from '@fortawesome/free-solid-svg-icons'; +import MkPagination from '../../components/ui/pagination.vue'; +import MkButton from '../../components/ui/button.vue'; + +export default Vue.extend({ + metaInfo() { + return { + title: this.$t('manageLists') as string, + }; + }, + + components: { + MkPagination, + MkButton, + }, + + data() { + return { + pagination: { + endpoint: 'users/lists/list', + limit: 10, + }, + faListUl, faPlus + }; + }, + + methods: { + async create() { + const { canceled, result: name } = await this.$root.dialog({ + title: this.$t('enterListName'), + input: true + }); + if (canceled) return; + await this.$root.api('users/lists/create', { name: name }); + this.$refs.list.reload(); + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.qkcjvfiv { + > .add { + margin: 0 auto 16px auto; + } + + > .lists { + > .list { + display: flex; + padding: 16px; + } + } +} +</style> diff --git a/src/client/pages/my-lists/list.vue b/src/client/pages/my-lists/list.vue new file mode 100644 index 0000000000..8899b4c44d --- /dev/null +++ b/src/client/pages/my-lists/list.vue @@ -0,0 +1,163 @@ +<template> +<div class="mk-list-page"> + <transition name="zoom" mode="out-in"> + <div v-if="list" :key="list.id" class="_section list"> + <div class="_title">{{ list.name }}</div> + <div class="_content"> + <div class="users"> + <div class="user" v-for="(user, i) in users" :key="user.id" :data-index="i"> + <mk-avatar :user="user" class="avatar"/> + <div class="body"> + <mk-user-name :user="user" class="name"/> + <mk-acct :user="user" class="acct"/> + </div> + <div class="action"> + <button class="_button" @click="removeUser(user)"><fa :icon="faTimes"/></button> + </div> + </div> + </div> + </div> + <div class="_footer"> + <mk-button inline @click="renameList()">{{ $t('renameList') }}</mk-button> + <mk-button inline @click="deleteList()">{{ $t('deleteList') }}</mk-button> + </div> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../../i18n'; +import Progress from '../../scripts/loading'; +import MkButton from '../../components/ui/button.vue'; + +export default Vue.extend({ + i18n, + + metaInfo() { + return { + title: this.list ? `${this.list.name} | ${this.$t('manageLists')}` : this.$t('manageLists') + }; + }, + + components: { + MkButton + }, + + data() { + return { + list: null, + users: [], + faTimes + }; + }, + + watch: { + $route: 'fetch' + }, + + created() { + this.fetch(); + }, + + methods: { + fetch() { + Progress.start(); + this.$root.api('users/lists/show', { + listId: this.$route.params.list + }).then(list => { + this.list = list; + this.$root.api('users/show', { + userIds: this.list.userIds + }).then(users => { + this.users = users; + Progress.done(); + }); + }); + }, + + removeUser(user) { + this.$root.api('users/lists/pull', { + listId: this.list.id, + userId: user.id + }).then(() => { + this.users = this.users.filter(x => x.id !== user.id); + }); + }, + + async renameList() { + const { canceled, result: name } = await this.$root.dialog({ + title: this.$t('enterListName'), + input: { + default: this.list.name + } + }); + if (canceled) return; + + await this.$root.api('users/lists/update', { + listId: this.list.id, + name: name + }); + + this.list.name = name; + }, + + async deleteList() { + const { canceled } = await this.$root.dialog({ + type: 'warning', + text: this.$t('deleteListConfirm', { list: this.list.name }), + showCancelButton: true + }); + if (canceled) return; + + await this.$root.api('users/lists/delete', { + listId: this.list.id + }); + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + this.$router.push('/my/lists'); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-list-page { + > .list { + > ._content { + max-height: 400px; + overflow: auto; + + > .users { + > .user { + display: flex; + align-items: center; + + > .avatar { + width: 50px; + height: 50px; + } + + > .body { + flex: 1; + padding: 8px; + + > .name { + display: block; + font-weight: bold; + } + + > .acct { + opacity: 0.5; + } + } + } + } + } + } +} +</style> diff --git a/src/client/pages/note.vue b/src/client/pages/note.vue new file mode 100644 index 0000000000..e7cdf19f81 --- /dev/null +++ b/src/client/pages/note.vue @@ -0,0 +1,55 @@ +<template> +<div class="mk-note-page"> + <transition name="zoom" mode="out-in"> + <x-note v-if="note" :note="note" :key="note.id" :detail="true"/> + <div v-else-if="error"> + <mk-error @retry="fetch()"/> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import Progress from '../scripts/loading'; +import XNote from '../components/note.vue'; + +export default Vue.extend({ + i18n, + metaInfo() { + return { + title: this.$t('note') as string + }; + }, + components: { + XNote + }, + data() { + return { + note: null, + error: null, + }; + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + methods: { + fetch() { + Progress.start(); + this.$root.api('notes/show', { + noteId: this.$route.params.note + }).then(note => { + this.note = note; + }).catch(e => { + this.error = e; + }).finally(() => { + Progress.done(); + }); + } + } +}); +</script> diff --git a/src/client/pages/page-editor/els/page-editor.el.button.vue b/src/client/pages/page-editor/els/page-editor.el.button.vue new file mode 100644 index 0000000000..8e74124b79 --- /dev/null +++ b/src/client/pages/page-editor/els/page-editor.el.button.vue @@ -0,0 +1,83 @@ +<template> +<x-container @remove="() => $emit('remove')" :draggable="true"> + <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.button') }}</template> + + <section class="xfhsjczc"> + <mk-input v-model="value.text"><span>{{ $t('_pages.blocks._button.text') }}</span></mk-input> + <mk-switch v-model="value.primary"><span>{{ $t('_pages.blocks._button.colored') }}</span></mk-switch> + <mk-select v-model="value.action"> + <template #label>{{ $t('_pages.blocks._button.action') }}</template> + <option value="dialog">{{ $t('_pages.blocks._button._action.dialog') }}</option> + <option value="resetRandom">{{ $t('_pages.blocks._button._action.resetRandom') }}</option> + <option value="pushEvent">{{ $t('_pages.blocks._button._action.pushEvent') }}</option> + </mk-select> + <template v-if="value.action === 'dialog'"> + <mk-input v-model="value.content"><span>{{ $t('_pages.blocks._button._action._dialog.content') }}</span></mk-input> + </template> + <template v-else-if="value.action === 'pushEvent'"> + <mk-input v-model="value.event"><span>{{ $t('_pages.blocks._button._action._pushEvent.event') }}</span></mk-input> + <mk-input v-model="value.message"><span>{{ $t('_pages.blocks._button._action._pushEvent.message') }}</span></mk-input> + <mk-select v-model="value.var"> + <template #label>{{ $t('_pages.blocks._button._action._pushEvent.variable') }}</template> + <option :value="null">{{ $t('_pages.blocks._button._action._pushEvent.no-variable') }}</option> + <option v-for="v in aiScript.getVarsByType()" :value="v.name">{{ v.name }}</option> + <optgroup :label="$t('_pages.script.pageVariables')"> + <option v-for="v in aiScript.getPageVarsByType()" :value="v">{{ v }}</option> + </optgroup> + <optgroup :label="$t('_pages.script.enviromentVariables')"> + <option v-for="v in aiScript.getEnvVarsByType()" :value="v">{{ v }}</option> + </optgroup> + </mk-select> + </template> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faBolt } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../../../i18n'; +import XContainer from '../page-editor.container.vue'; +import MkSelect from '../../../components/ui/select.vue'; +import MkInput from '../../../components/ui/input.vue'; +import MkSwitch from '../../../components/ui/switch.vue'; + +export default Vue.extend({ + i18n, + + components: { + XContainer, MkSelect, MkInput, MkSwitch + }, + + props: { + value: { + required: true + }, + aiScript: { + required: true, + }, + }, + + data() { + return { + faBolt + }; + }, + + created() { + if (this.value.text == null) Vue.set(this.value, 'text', ''); + if (this.value.action == null) Vue.set(this.value, 'action', 'dialog'); + if (this.value.content == null) Vue.set(this.value, 'content', null); + if (this.value.event == null) Vue.set(this.value, 'event', null); + if (this.value.message == null) Vue.set(this.value, 'message', null); + if (this.value.primary == null) Vue.set(this.value, 'primary', false); + if (this.value.var == null) Vue.set(this.value, 'var', null); + }, +}); +</script> + +<style lang="scss" scoped> +.xfhsjczc { + padding: 0 16px 0 16px; +} +</style> diff --git a/src/client/app/common/views/pages/page-editor/els/page-editor.el.counter.vue b/src/client/pages/page-editor/els/page-editor.el.counter.vue index 4fc2aac8fc..d9a4ddddee 100644 --- a/src/client/app/common/views/pages/page-editor/els/page-editor.el.counter.vue +++ b/src/client/pages/page-editor/els/page-editor.el.counter.vue @@ -1,11 +1,11 @@ <template> <x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faBolt"/> {{ $t('blocks.counter') }}</template> + <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.counter') }}</template> <section style="padding: 0 16px 0 16px;"> - <ui-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('blocks._counter.name') }}</span></ui-input> - <ui-input v-model="value.text"><span>{{ $t('blocks._counter.text') }}</span></ui-input> - <ui-input v-model="value.inc" type="number"><span>{{ $t('blocks._counter.inc') }}</span></ui-input> + <mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._counter.name') }}</span></mk-input> + <mk-input v-model="value.text"><span>{{ $t('_pages.blocks._counter.text') }}</span></mk-input> + <mk-input v-model="value.inc" type="number"><span>{{ $t('_pages.blocks._counter.inc') }}</span></mk-input> </section> </x-container> </template> @@ -13,14 +13,15 @@ <script lang="ts"> import Vue from 'vue'; import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons'; -import i18n from '../../../../../i18n'; +import i18n from '../../../i18n'; import XContainer from '../page-editor.container.vue'; +import MkInput from '../../../components/ui/input.vue'; export default Vue.extend({ - i18n: i18n('pages'), + i18n, components: { - XContainer + XContainer, MkInput }, props: { diff --git a/src/client/app/common/views/pages/page-editor/els/page-editor.el.if.vue b/src/client/pages/page-editor/els/page-editor.el.if.vue index a3743d89d6..3c545a7ddc 100644 --- a/src/client/app/common/views/pages/page-editor/els/page-editor.el.if.vue +++ b/src/client/pages/page-editor/els/page-editor.el.if.vue @@ -1,6 +1,6 @@ <template> <x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faQuestion"/> {{ $t('blocks.if') }}</template> + <template #header><fa :icon="faQuestion"/> {{ $t('_pages.blocks.if') }}</template> <template #func> <button @click="add()"> <fa :icon="faPlus"/> @@ -8,16 +8,16 @@ </template> <section class="romcojzs"> - <ui-select v-model="value.var"> - <template #label>{{ $t('blocks._if.variable') }}</template> + <mk-select v-model="value.var"> + <template #label>{{ $t('_pages.blocks._if.variable') }}</template> <option v-for="v in aiScript.getVarsByType('boolean')" :value="v.name">{{ v.name }}</option> - <optgroup :label="$t('script.pageVariables')"> + <optgroup :label="$t('_pages.script.pageVariables')"> <option v-for="v in aiScript.getPageVarsByType('boolean')" :value="v">{{ v }}</option> </optgroup> - <optgroup :label="$t('script.enviromentVariables')"> + <optgroup :label="$t('_pages.script.enviromentVariables')"> <option v-for="v in aiScript.getEnvVarsByType('boolean')" :value="v">{{ v }}</option> </optgroup> - </ui-select> + </mk-select> <x-blocks class="children" v-model="value.children" :ai-script="aiScript"/> </section> @@ -28,14 +28,15 @@ import Vue from 'vue'; import { v4 as uuid } from 'uuid'; import { faPlus, faQuestion } from '@fortawesome/free-solid-svg-icons'; -import i18n from '../../../../../i18n'; +import i18n from '../../../i18n'; import XContainer from '../page-editor.container.vue'; +import MkSelect from '../../../components/ui/select.vue'; export default Vue.extend({ - i18n: i18n('pages'), + i18n, components: { - XContainer + XContainer, MkSelect }, inject: ['getPageBlockList'], @@ -83,8 +84,8 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.romcojzs - padding 0 16px 16px 16px - +<style lang="scss" scoped> +.romcojzs { + padding: 0 16px 16px 16px; +} </style> diff --git a/src/client/app/common/views/pages/page-editor/els/page-editor.el.image.vue b/src/client/pages/page-editor/els/page-editor.el.image.vue index e2e72b04c2..e22701e5c0 100644 --- a/src/client/app/common/views/pages/page-editor/els/page-editor.el.image.vue +++ b/src/client/pages/page-editor/els/page-editor.el.image.vue @@ -1,6 +1,6 @@ <template> <x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faImage"/> {{ $t('blocks.image') }}</template> + <template #header><fa :icon="faImage"/> {{ $t('_pages.blocks.image') }}</template> <template #func> <button @click="choose()"> <fa :icon="faFolderOpen"/> @@ -8,7 +8,7 @@ </template> <section class="oyyftmcf"> - <x-file-thumbnail class="preview" v-if="file" :file="file" :detail="true" fit="contain" @click="choose()"/> + <mk-file-thumbnail class="preview" v-if="file" :file="file" :detail="true" fit="contain" @click="choose()"/> </section> </x-container> </template> @@ -17,15 +17,16 @@ import Vue from 'vue'; import { faPencilAlt } from '@fortawesome/free-solid-svg-icons'; import { faImage, faFolderOpen } from '@fortawesome/free-regular-svg-icons'; -import i18n from '../../../../../i18n'; +import i18n from '../../../i18n'; import XContainer from '../page-editor.container.vue'; -import XFileThumbnail from '../../../components/drive-file-thumbnail.vue'; +import MkFileThumbnail from '../../../components/drive-file-thumbnail.vue'; +import { selectDriveFile } from '../../../scripts/select-drive-file'; export default Vue.extend({ - i18n: i18n('pages'), + i18n, components: { - XContainer, XFileThumbnail + XContainer, MkFileThumbnail }, props: { @@ -59,9 +60,7 @@ export default Vue.extend({ methods: { async choose() { - this.$chooseDriveFile({ - multiple: false - }).then(file => { + selectDriveFile(this.$root, false).then(file => { this.file = file; this.value.fileId = file.id; }); @@ -70,9 +69,10 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.oyyftmcf - > .preview - height 150px - +<style lang="scss" scoped> +.oyyftmcf { + > .preview { + height: 150px; + } +} </style> diff --git a/src/client/pages/page-editor/els/page-editor.el.number-input.vue b/src/client/pages/page-editor/els/page-editor.el.number-input.vue new file mode 100644 index 0000000000..76dd254464 --- /dev/null +++ b/src/client/pages/page-editor/els/page-editor.el.number-input.vue @@ -0,0 +1,43 @@ +<template> +<x-container @remove="() => $emit('remove')" :draggable="true"> + <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.numberInput') }}</template> + + <section style="padding: 0 16px 0 16px;"> + <mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._numberInput.name') }}</span></mk-input> + <mk-input v-model="value.text"><span>{{ $t('_pages.blocks._numberInput.text') }}</span></mk-input> + <mk-input v-model="value.default" type="number"><span>{{ $t('_pages.blocks._numberInput.default') }}</span></mk-input> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../../../i18n'; +import XContainer from '../page-editor.container.vue'; +import MkInput from '../../../components/ui/input.vue'; + +export default Vue.extend({ + i18n, + + components: { + XContainer, MkInput + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + faBolt, faMagic + }; + }, + + created() { + if (this.value.name == null) Vue.set(this.value, 'name', ''); + }, +}); +</script> diff --git a/src/client/app/common/views/pages/page-editor/els/page-editor.el.post.vue b/src/client/pages/page-editor/els/page-editor.el.post.vue index fc2f5f9032..10ec885d0f 100644 --- a/src/client/app/common/views/pages/page-editor/els/page-editor.el.post.vue +++ b/src/client/pages/page-editor/els/page-editor.el.post.vue @@ -1,9 +1,9 @@ <template> <x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faPaperPlane"/> {{ $t('blocks.post') }}</template> + <template #header><fa :icon="faPaperPlane"/> {{ $t('_pages.blocks.post') }}</template> <section style="padding: 0 16px 16px 16px;"> - <ui-textarea v-model="value.text">{{ $t('blocks._post.text') }}</ui-textarea> + <mk-textarea v-model="value.text">{{ $t('_pages.blocks._post.text') }}</mk-textarea> </section> </x-container> </template> @@ -11,14 +11,15 @@ <script lang="ts"> import Vue from 'vue'; import { faPaperPlane } from '@fortawesome/free-regular-svg-icons'; -import i18n from '../../../../../i18n'; +import i18n from '../../../i18n'; import XContainer from '../page-editor.container.vue'; +import MkTextarea from '../../../components/ui/textarea.vue'; export default Vue.extend({ - i18n: i18n('pages'), + i18n, components: { - XContainer + XContainer, MkTextarea }, props: { diff --git a/src/client/app/common/views/pages/page-editor/els/page-editor.el.radio-button.vue b/src/client/pages/page-editor/els/page-editor.el.radio-button.vue index 3401c46f47..8d404ec0df 100644 --- a/src/client/app/common/views/pages/page-editor/els/page-editor.el.radio-button.vue +++ b/src/client/pages/page-editor/els/page-editor.el.radio-button.vue @@ -1,12 +1,12 @@ <template> <x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faBolt"/> {{ $t('blocks.radioButton') }}</template> + <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.radioButton') }}</template> <section style="padding: 0 16px 16px 16px;"> - <ui-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('blocks._radioButton.name') }}</span></ui-input> - <ui-input v-model="value.title"><span>{{ $t('blocks._radioButton.title') }}</span></ui-input> - <ui-textarea v-model="values"><span>{{ $t('blocks._radioButton.values') }}</span></ui-textarea> - <ui-input v-model="value.default"><span>{{ $t('blocks._radioButton.default') }}</span></ui-input> + <mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._radioButton.name') }}</span></mk-input> + <mk-input v-model="value.title"><span>{{ $t('_pages.blocks._radioButton.title') }}</span></mk-input> + <mk-textarea v-model="values"><span>{{ $t('_pages.blocks._radioButton.values') }}</span></mk-textarea> + <mk-input v-model="value.default"><span>{{ $t('_pages.blocks._radioButton.default') }}</span></mk-input> </section> </x-container> </template> @@ -14,35 +14,32 @@ <script lang="ts"> import Vue from 'vue'; import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons'; -import i18n from '../../../../../i18n'; +import i18n from '../../../i18n'; import XContainer from '../page-editor.container.vue'; +import MkTextarea from '../../../components/ui/textarea.vue'; +import MkInput from '../../../components/ui/input.vue'; export default Vue.extend({ - i18n: i18n('pages'), - + i18n, components: { - XContainer + XContainer, MkTextarea, MkInput }, - props: { value: { required: true }, }, - data() { return { values: '', faBolt, faMagic }; }, - watch: { values() { Vue.set(this.value, 'values', this.values.split('\n')); } }, - created() { if (this.value.name == null) Vue.set(this.value, 'name', ''); if (this.value.title == null) Vue.set(this.value, 'title', ''); diff --git a/src/client/app/common/views/pages/page-editor/els/page-editor.el.section.vue b/src/client/pages/page-editor/els/page-editor.el.section.vue index 0f8f850947..d405ee1965 100644 --- a/src/client/app/common/views/pages/page-editor/els/page-editor.el.section.vue +++ b/src/client/pages/page-editor/els/page-editor.el.section.vue @@ -21,11 +21,11 @@ import Vue from 'vue'; import { v4 as uuid } from 'uuid'; import { faPlus, faPencilAlt } from '@fortawesome/free-solid-svg-icons'; import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; -import i18n from '../../../../../i18n'; +import i18n from '../../../i18n'; import XContainer from '../page-editor.container.vue'; export default Vue.extend({ - i18n: i18n('pages'), + i18n, components: { XContainer @@ -95,9 +95,10 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.ilrvjyvi - > .children - padding 16px - +<style lang="scss" scoped> +.ilrvjyvi { + > .children { + padding: 16px; + } +} </style> diff --git a/src/client/pages/page-editor/els/page-editor.el.switch.vue b/src/client/pages/page-editor/els/page-editor.el.switch.vue new file mode 100644 index 0000000000..8f169c3d23 --- /dev/null +++ b/src/client/pages/page-editor/els/page-editor.el.switch.vue @@ -0,0 +1,50 @@ +<template> +<x-container @remove="() => $emit('remove')" :draggable="true"> + <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.switch') }}</template> + + <section class="kjuadyyj"> + <mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._switch.name') }}</span></mk-input> + <mk-input v-model="value.text"><span>{{ $t('_pages.blocks._switch.text') }}</span></mk-input> + <mk-switch v-model="value.default"><span>{{ $t('_pages.blocks._switch.default') }}</span></mk-switch> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../../../i18n'; +import XContainer from '../page-editor.container.vue'; +import MkSwitch from '../../../components/ui/switch.vue'; +import MkInput from '../../../components/ui/input.vue'; + +export default Vue.extend({ + i18n, + + components: { + XContainer, MkSwitch, MkInput + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + faBolt, faMagic + }; + }, + + created() { + if (this.value.name == null) Vue.set(this.value, 'name', ''); + }, +}); +</script> + +<style lang="scss" scoped> +.kjuadyyj { + padding: 0 16px 16px 16px; +} +</style> diff --git a/src/client/pages/page-editor/els/page-editor.el.text-input.vue b/src/client/pages/page-editor/els/page-editor.el.text-input.vue new file mode 100644 index 0000000000..7c9e3d6a0e --- /dev/null +++ b/src/client/pages/page-editor/els/page-editor.el.text-input.vue @@ -0,0 +1,43 @@ +<template> +<x-container @remove="() => $emit('remove')" :draggable="true"> + <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.textInput') }}</template> + + <section style="padding: 0 16px 0 16px;"> + <mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._textInput.name') }}</span></mk-input> + <mk-input v-model="value.text"><span>{{ $t('_pages.blocks._textInput.text') }}</span></mk-input> + <mk-input v-model="value.default" type="text"><span>{{ $t('_pages.blocks._textInput.default') }}</span></mk-input> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../../../i18n'; +import XContainer from '../page-editor.container.vue'; +import MkInput from '../../../components/ui/input.vue'; + +export default Vue.extend({ + i18n, + + components: { + XContainer, MkInput + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + faBolt, faMagic + }; + }, + + created() { + if (this.value.name == null) Vue.set(this.value, 'name', ''); + }, +}); +</script> diff --git a/src/client/app/common/views/pages/page-editor/els/page-editor.el.text.vue b/src/client/pages/page-editor/els/page-editor.el.text.vue index c09f9cc1cf..00b6cd8a36 100644 --- a/src/client/app/common/views/pages/page-editor/els/page-editor.el.text.vue +++ b/src/client/pages/page-editor/els/page-editor.el.text.vue @@ -1,6 +1,6 @@ <template> <x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faAlignLeft"/> {{ $t('blocks.text') }}</template> + <template #header><fa :icon="faAlignLeft"/> {{ $t('_pages.blocks.text') }}</template> <section class="ihymsbbe"> <textarea v-model="value.text"></textarea> @@ -11,11 +11,11 @@ <script lang="ts"> import Vue from 'vue'; import { faAlignLeft } from '@fortawesome/free-solid-svg-icons'; -import i18n from '../../../../../i18n'; +import i18n from '../../../i18n'; import XContainer from '../page-editor.container.vue'; export default Vue.extend({ - i18n: i18n('pages'), + i18n, components: { XContainer @@ -39,20 +39,22 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.ihymsbbe - > textarea - display block - -webkit-appearance none - -moz-appearance none - appearance none - width 100% - min-width 100% - min-height 150px - border none - box-shadow none - padding 16px - background transparent - color var(--text) - font-size 14px +<style lang="scss" scoped> +.ihymsbbe { + > textarea { + display: block; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 100%; + min-width: 100%; + min-height: 150px; + border: none; + box-shadow: none; + padding: 16px; + background: transparent; + color: var(--fg); + font-size: 14px; + } +} </style> diff --git a/src/client/pages/page-editor/els/page-editor.el.textarea-input.vue b/src/client/pages/page-editor/els/page-editor.el.textarea-input.vue new file mode 100644 index 0000000000..8081e706bc --- /dev/null +++ b/src/client/pages/page-editor/els/page-editor.el.textarea-input.vue @@ -0,0 +1,44 @@ +<template> +<x-container @remove="() => $emit('remove')" :draggable="true"> + <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.textareaInput') }}</template> + + <section style="padding: 0 16px 16px 16px;"> + <mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._textareaInput.name') }}</span></mk-input> + <mk-input v-model="value.text"><span>{{ $t('_pages.blocks._textareaInput.text') }}</span></mk-input> + <mk-textarea v-model="value.default"><span>{{ $t('_pages.blocks._textareaInput.default') }}</span></mk-textarea> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../../../i18n'; +import XContainer from '../page-editor.container.vue'; +import MkTextarea from '../../../components/ui/textarea.vue'; +import MkInput from '../../../components/ui/input.vue'; + +export default Vue.extend({ + i18n, + + components: { + XContainer, MkTextarea, MkInput + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + faBolt, faMagic + }; + }, + + created() { + if (this.value.name == null) Vue.set(this.value, 'name', ''); + }, +}); +</script> diff --git a/src/client/app/common/views/pages/page-editor/els/page-editor.el.textarea.vue b/src/client/pages/page-editor/els/page-editor.el.textarea.vue index a0cc1966e8..fd75849684 100644 --- a/src/client/app/common/views/pages/page-editor/els/page-editor.el.textarea.vue +++ b/src/client/pages/page-editor/els/page-editor.el.textarea.vue @@ -1,6 +1,6 @@ <template> <x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faAlignLeft"/> {{ $t('blocks.textarea') }}</template> + <template #header><fa :icon="faAlignLeft"/> {{ $t('_pages.blocks.textarea') }}</template> <section class="ihymsbbe"> <textarea v-model="value.text"></textarea> @@ -11,11 +11,11 @@ <script lang="ts"> import Vue from 'vue'; import { faAlignLeft } from '@fortawesome/free-solid-svg-icons'; -import i18n from '../../../../../i18n'; +import i18n from '../../../i18n'; import XContainer from '../page-editor.container.vue'; export default Vue.extend({ - i18n: i18n('pages'), + i18n, components: { XContainer @@ -39,20 +39,22 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.ihymsbbe - > textarea - display block - -webkit-appearance none - -moz-appearance none - appearance none - width 100% - min-width 100% - min-height 150px - border none - box-shadow none - padding 16px - background transparent - color var(--text) - font-size 14px +<style lang="scss" scoped> +.ihymsbbe { + > textarea { + display: block; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 100%; + min-width: 100%; + min-height: 150px; + border: none; + box-shadow: none; + padding: 16px; + background: transparent; + color: var(--fg); + font-size: 14px; + } +} </style> diff --git a/src/client/app/common/views/pages/page-editor/page-editor.blocks.vue b/src/client/pages/page-editor/page-editor.blocks.vue index 4d7293231f..4d7293231f 100644 --- a/src/client/app/common/views/pages/page-editor/page-editor.blocks.vue +++ b/src/client/pages/page-editor/page-editor.blocks.vue diff --git a/src/client/pages/page-editor/page-editor.container.vue b/src/client/pages/page-editor/page-editor.container.vue new file mode 100644 index 0000000000..5a4f096c7f --- /dev/null +++ b/src/client/pages/page-editor/page-editor.container.vue @@ -0,0 +1,152 @@ +<template> +<div class="cpjygsrt" :class="{ error: error != null, warn: warn != null }"> + <header> + <div class="title"><slot name="header"></slot></div> + <div class="buttons"> + <slot name="func"></slot> + <button v-if="removable" @click="remove()" class="_button"> + <fa :icon="faTrashAlt"/> + </button> + <button v-if="draggable" class="drag-handle _button"> + <fa :icon="faBars"/> + </button> + <button @click="toggleContent(!showBody)" class="_button"> + <template v-if="showBody"><fa :icon="faAngleUp"/></template> + <template v-else><fa :icon="faAngleDown"/></template> + </button> + </div> + </header> + <p v-show="showBody" class="error" v-if="error != null">{{ $t('_pages.script.typeError', { slot: error.arg + 1, expect: $t(`script.types.${error.expect}`), actual: $t(`script.types.${error.actual}`) }) }}</p> + <p v-show="showBody" class="warn" v-if="warn != null">{{ $t('_pages.script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p> + <div v-show="showBody"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faBars, faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons'; +import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../../i18n'; + +export default Vue.extend({ + i18n, + + props: { + expanded: { + type: Boolean, + default: true + }, + removable: { + type: Boolean, + default: true + }, + draggable: { + type: Boolean, + default: false + }, + error: { + required: false, + default: null + }, + warn: { + required: false, + default: null + } + }, + data() { + return { + showBody: this.expanded, + faTrashAlt, faBars, faAngleUp, faAngleDown + }; + }, + methods: { + toggleContent(show: boolean) { + this.showBody = show; + this.$emit('toggle', show); + }, + remove() { + this.$emit('remove'); + } + } +}); +</script> + +<style lang="scss" scoped> +.cpjygsrt { + position: relative; + overflow: hidden; + background: var(--panel); + border: solid 2px var(--jvhmlskx); + border-radius: 6px; + + &:hover { + border: solid 2px var(--yakfpmhl); + } + + &.warn { + border: solid 2px #dec44c; + } + + &.error { + border: solid 2px #f00; + } + + & + .cpjygsrt { + margin-top: 16px; + } + + > header { + > .title { + z-index: 1; + margin: 0; + padding: 0 16px; + line-height: 42px; + font-size: 0.9em; + font-weight: bold; + box-shadow: 0 1px rgba(#000, 0.07); + + > [data-icon] { + margin-right: 6px; + } + + &:empty { + display: none; + } + } + + > .buttons { + position: absolute; + z-index: 2; + top: 0; + right: 0; + + > button { + padding: 0; + width: 42px; + font-size: 0.9em; + line-height: 42px; + } + + .drag-handle { + cursor: move; + } + } + } + + > .warn { + color: #b19e49; + margin: 0; + padding: 16px 16px 0 16px; + font-size: 14px; + } + + > .error { + color: #f00; + margin: 0; + padding: 16px 16px 0 16px; + font-size: 14px; + } +} +</style> diff --git a/src/client/app/common/views/pages/page-editor/page-editor.script-block.vue b/src/client/pages/page-editor/page-editor.script-block.vue index cf76cc003e..ae56803a39 100644 --- a/src/client/app/common/views/pages/page-editor/page-editor.script-block.vue +++ b/src/client/pages/page-editor/page-editor.script-block.vue @@ -8,7 +8,7 @@ </template> <section v-if="value.type === null" class="pbglfege" @click="changeType()"> - {{ $t('script.emptySlot') }} + {{ $t('_pages.script.emptySlot') }} </section> <section v-else-if="value.type === 'text'" class="tbwccoaw"> <input v-model="value.value"/> @@ -17,7 +17,7 @@ <textarea v-model="value.value"></textarea> </section> <section v-else-if="value.type === 'textList'" class="tbwccoaw"> - <textarea v-model="value.value" :placeholder="$t('script.blocks._textList.info')"></textarea> + <textarea v-model="value.value" :placeholder="$t('_pages.script.blocks._textList.info')"></textarea> </section> <section v-else-if="value.type === 'number'" class="tbwccoaw"> <input v-model="value.value" type="number"/> @@ -25,46 +25,47 @@ <section v-else-if="value.type === 'ref'" class="hpdwcrvs"> <select v-model="value.value"> <option v-for="v in aiScript.getVarsByType(getExpectedType ? getExpectedType() : null).filter(x => x.name !== name)" :value="v.name">{{ v.name }}</option> - <optgroup :label="$t('script.argVariables')"> + <optgroup :label="$t('_pages.script.argVariables')"> <option v-for="v in fnSlots" :value="v.name">{{ v.name }}</option> </optgroup> - <optgroup :label="$t('script.pageVariables')"> + <optgroup :label="$t('_pages.script.pageVariables')"> <option v-for="v in aiScript.getPageVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option> </optgroup> - <optgroup :label="$t('script.enviromentVariables')"> + <optgroup :label="$t('_pages.script.enviromentVariables')"> <option v-for="v in aiScript.getEnvVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option> </optgroup> </select> </section> <section v-else-if="value.type === 'fn'" class="" style="padding:0 16px 16px 16px;"> - <ui-textarea v-model="slots"> - <span>{{ $t('script.blocks._fn.slots') }}</span> - <template #desc>{{ $t('script.blocks._fn.slots-info') }}</template> - </ui-textarea> - <x-v v-if="value.value.expression" v-model="value.value.expression" :title="$t(`script.blocks._fn.arg1`)" :get-expected-type="() => null" :ai-script="aiScript" :fn-slots="value.value.slots" :name="name"/> + <mk-textarea v-model="slots"> + <span>{{ $t('_pages.script.blocks._fn.slots') }}</span> + <template #desc>{{ $t('_pages.script.blocks._fn.slots-info') }}</template> + </mk-textarea> + <x-v v-if="value.value.expression" v-model="value.value.expression" :title="$t(`_pages.script.blocks._fn.arg1`)" :get-expected-type="() => null" :ai-script="aiScript" :fn-slots="value.value.slots" :name="name"/> </section> <section v-else-if="value.type.startsWith('fn:')" class="" style="padding:16px;"> <x-v v-for="(x, i) in value.args" v-model="value.args[i]" :title="aiScript.getVarByName(value.type.split(':')[1]).value.slots[i].name" :get-expected-type="() => null" :ai-script="aiScript" :name="name" :key="i"/> </section> <section v-else class="" style="padding:16px;"> - <x-v v-for="(x, i) in value.args" v-model="value.args[i]" :title="$t(`script.blocks._${value.type}.arg${i + 1}`)" :get-expected-type="() => _getExpectedType(i)" :ai-script="aiScript" :name="name" :fn-slots="fnSlots" :key="i"/> + <x-v v-for="(x, i) in value.args" v-model="value.args[i]" :title="$t(`_pages.script.blocks._${value.type}.arg${i + 1}`)" :get-expected-type="() => _getExpectedType(i)" :ai-script="aiScript" :name="name" :fn-slots="fnSlots" :key="i"/> </section> </x-container> </template> <script lang="ts"> import Vue from 'vue'; -import i18n from '../../../../i18n'; -import XContainer from './page-editor.container.vue'; import { faPencilAlt, faPlug } from '@fortawesome/free-solid-svg-icons'; -import { isLiteralBlock, funcDefs, blockDefs } from '../../../../../../misc/aiscript/index'; import { v4 as uuid } from 'uuid'; +import i18n from '../../i18n'; +import XContainer from './page-editor.container.vue'; +import MkTextarea from '../../components/ui/textarea.vue'; +import { isLiteralBlock, funcDefs, blockDefs } from '../../scripts/aiscript/index'; export default Vue.extend({ - i18n: i18n('pages'), + i18n, components: { - XContainer + XContainer, MkTextarea }, inject: ['getScriptBlockList'], @@ -117,7 +118,7 @@ export default Vue.extend({ typeText(): any { if (this.value.type === null) return null; if (this.value.type.startsWith('fn:')) return this.value.type.split(':')[1]; - return this.$t(`script.blocks.${this.value.type}`); + return this.$t(`_pages.script.blocks.${this.value.type}`); }, }, @@ -228,44 +229,50 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.turmquns - opacity 0.7 - -.pbglfege - opacity 0.5 - padding 16px - text-align center - cursor pointer - color var(--text) +<style lang="scss" scoped> +.turmquns { + opacity: 0.7; +} -.tbwccoaw - > input - > textarea - display block - -webkit-appearance none - -moz-appearance none - appearance none - width 100% - max-width 100% - min-width 100% - border none - box-shadow none - padding 16px - font-size 16px - background transparent - color var(--text) +.pbglfege { + opacity: 0.5; + padding: 16px; + text-align: center; + cursor: pointer; + color: var(--fg); +} - > textarea - min-height 100px +.tbwccoaw { + > input, + > textarea { + display: block; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 100%; + max-width: 100%; + min-width: 100%; + border: none; + box-shadow: none; + padding: 16px; + font-size: 16px; + background: transparent; + color: var(--fg); + } -.hpdwcrvs - padding 16px + > textarea { + min-height: 100px; + } +} - > select - display block - padding 4px - font-size 16px - width 100% +.hpdwcrvs { + padding: 16px; + > select { + display: block; + padding: 4px; + font-size: 16px; + width: 100%; + } +} </style> diff --git a/src/client/app/common/views/pages/page-editor/page-editor.vue b/src/client/pages/page-editor/page-editor.vue index cbe65ad6f0..a5a4588f13 100644 --- a/src/client/app/common/views/pages/page-editor/page-editor.vue +++ b/src/client/pages/page-editor/page-editor.vue @@ -1,58 +1,58 @@ <template> <div> - <div class="gwbmwxkm" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> + <div class="gwbmwxkm _panel"> <header> <div class="title"><fa :icon="faStickyNote"/> {{ readonly ? $t('read-page') : pageId ? $t('edit-page') : $t('new-page') }}</div> <div class="buttons"> - <button @click="del()" v-if="!readonly"><fa :icon="faTrashAlt"/></button> - <button @click="() => showOptions = !showOptions"><fa :icon="faCog"/></button> - <button @click="save()" v-if="!readonly"><fa :icon="faSave"/></button> + <button class="_button" @click="del()" v-if="!readonly"><fa :icon="faTrashAlt"/></button> + <button class="_button" @click="() => showOptions = !showOptions"><fa :icon="faCog"/></button> + <button class="_button" @click="save()" v-if="!readonly"><fa :icon="faSave"/></button> </div> </header> <section> <router-link class="view" v-if="pageId" :to="`/@${ author.username }/pages/${ currentName }`"><fa :icon="faExternalLinkSquareAlt"/> {{ $t('view-page') }}</router-link> - <ui-input v-model="title"> + <mk-input v-model="title"> <span>{{ $t('title') }}</span> - </ui-input> + </mk-input> <template v-if="showOptions"> - <ui-input v-model="summary"> + <mk-input v-model="summary"> <span>{{ $t('summary') }}</span> - </ui-input> + </mk-input> - <ui-input v-model="name"> + <mk-input v-model="name"> <template #prefix>{{ url }}/@{{ author.username }}/pages/</template> <span>{{ $t('url') }}</span> - </ui-input> + </mk-input> - <ui-switch v-model="alignCenter">{{ $t('align-center') }}</ui-switch> + <mk-switch v-model="alignCenter">{{ $t('align-center') }}</mk-switch> - <ui-select v-model="font"> + <mk-select v-model="font"> <template #label>{{ $t('font') }}</template> <option value="serif">{{ $t('fontSerif') }}</option> <option value="sans-serif">{{ $t('fontSansSerif') }}</option> - </ui-select> + </mk-select> - <ui-switch v-model="hideTitleWhenPinned">{{ $t('hide-title-when-pinned') }}</ui-switch> + <mk-switch v-model="hideTitleWhenPinned">{{ $t('hide-title-when-pinned') }}</mk-switch> <div class="eyeCatch"> - <ui-button v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage()"><fa :icon="faPlus"/> {{ $t('set-eye-catching-image') }}</ui-button> + <mk-button v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage()"><fa :icon="faPlus"/> {{ $t('set-eye-catching-image') }}</mk-button> <div v-else-if="eyeCatchingImage"> <img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name"/> - <ui-button @click="removeEyeCatchingImage()" v-if="!readonly"><fa :icon="faTrashAlt"/> {{ $t('remove-eye-catching-image') }}</ui-button> + <mk-button @click="removeEyeCatchingImage()" v-if="!readonly"><fa :icon="faTrashAlt"/> {{ $t('remove-eye-catching-image') }}</mk-button> </div> </div> </template> <x-blocks class="content" v-model="content" :ai-script="aiScript"/> - <ui-button @click="add()" v-if="!readonly"><fa :icon="faPlus"/></ui-button> + <mk-button @click="add()" v-if="!readonly"><fa :icon="faPlus"/></mk-button> </section> </div> - <ui-container :body-togglable="true"> + <mk-container :body-togglable="true"> <template #header><fa :icon="faMagic"/> {{ $t('variables') }}</template> <div class="qmuvgica"> <x-draggable tag="div" class="variables" v-show="variables.length > 0" :list="variables" handle=".drag-handle" :group="{ name: 'variables' }" animation="150" swap-threshold="0.5"> @@ -69,25 +69,25 @@ /> </x-draggable> - <ui-button @click="addVariable()" class="add" v-if="!readonly"><fa :icon="faPlus"/></ui-button> + <mk-button @click="addVariable()" class="add" v-if="!readonly"><fa :icon="faPlus"/></mk-button> - <ui-info><span v-html="$t('variables-info')"></span><a @click="() => moreDetails = true" style="display:block;">{{ $t('more-details') }}</a></ui-info> + <x-info><span v-html="$t('variables-info')"></span><a @click="() => moreDetails = true" style="display:block;">{{ $t('more-details') }}</a></x-info> <template v-if="moreDetails"> - <ui-info><span v-html="$t('variables-info2')"></span></ui-info> - <ui-info><span v-html="$t('variables-info3')"></span></ui-info> - <ui-info><span v-html="$t('variables-info4')"></span></ui-info> + <x-info><span v-html="$t('variables-info2')"></span></x-info> + <x-info><span v-html="$t('variables-info3')"></span></x-info> + <x-info><span v-html="$t('variables-info4')"></span></x-info> </template> </div> - </ui-container> + </mk-container> - <ui-container :body-togglable="true" :expanded="false"> + <mk-container :body-togglable="true" :expanded="false"> <template #header><fa :icon="faCode"/> {{ $t('inspector') }}</template> <div style="padding:0 32px 32px 32px;"> - <ui-textarea :value="JSON.stringify(content, null, 2)" readonly tall>{{ $t('content') }}</ui-textarea> - <ui-textarea :value="JSON.stringify(variables, null, 2)" readonly tall>{{ $t('variables') }}</ui-textarea> + <mk-textarea :value="JSON.stringify(content, null, 2)" readonly tall>{{ $t('content') }}</mk-textarea> + <mk-textarea :value="JSON.stringify(variables, null, 2)" readonly tall>{{ $t('variables') }}</mk-textarea> </div> - </ui-container> + </mk-container> </div> </template> @@ -96,20 +96,26 @@ import Vue from 'vue'; import * as XDraggable from 'vuedraggable'; import { faICursor, faPlus, faMagic, faCog, faCode, faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons'; import { faSave, faStickyNote, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; -import i18n from '../../../../i18n'; +import { v4 as uuid } from 'uuid'; +import i18n from '../../i18n'; import XVariable from './page-editor.script-block.vue'; import XBlocks from './page-editor.blocks.vue'; -import { v4 as uuid } from 'uuid'; -import { blockDefs } from '../../../../../../misc/aiscript/index'; -import { ASTypeChecker } from '../../../../../../misc/aiscript/type-checker'; -import { url } from '../../../../config'; -import { collectPageVars } from '../../../scripts/collect-page-vars'; +import MkTextarea from '../../components/ui/textarea.vue'; +import MkContainer from '../../components/ui/container.vue'; +import MkButton from '../../components/ui/button.vue'; +import MkSelect from '../../components/ui/select.vue'; +import MkSwitch from '../../components/ui/switch.vue'; +import MkInput from '../../components/ui/input.vue'; +import { blockDefs } from '../../scripts/aiscript/index'; +import { ASTypeChecker } from '../../scripts/aiscript/type-checker'; +import { url } from '../../config'; +import { collectPageVars } from '../../scripts/collect-page-vars'; export default Vue.extend({ - i18n: i18n('pages'), + i18n, components: { - XDraggable, XVariable, XBlocks + XDraggable, XVariable, XBlocks, MkTextarea, MkContainer, MkButton, MkSelect, MkSwitch, MkInput }, props: { @@ -268,7 +274,7 @@ export default Vue.extend({ type: 'success', text: this.$t('page-created') }); - this.$router.push(`/i/pages/edit/${this.pageId}`); + this.$router.push(`/my/pages/edit/${this.pageId}`); }).catch(onError); } }, @@ -287,7 +293,7 @@ export default Vue.extend({ type: 'success', text: this.$t('page-deleted') }); - this.$router.push(`/i/pages`); + this.$router.push(`/my/pages`); }); }); }, @@ -344,27 +350,27 @@ export default Vue.extend({ return [{ label: this.$t('content-blocks'), items: [ - { value: 'section', text: this.$t('blocks.section') }, - { value: 'text', text: this.$t('blocks.text') }, - { value: 'image', text: this.$t('blocks.image') }, - { value: 'textarea', text: this.$t('blocks.textarea') }, + { value: 'section', text: this.$t('_pages.blocks.section') }, + { value: 'text', text: this.$t('_pages.blocks.text') }, + { value: 'image', text: this.$t('_pages.blocks.image') }, + { value: 'textarea', text: this.$t('_pages.blocks.textarea') }, ] }, { label: this.$t('input-blocks'), items: [ - { value: 'button', text: this.$t('blocks.button') }, - { value: 'radioButton', text: this.$t('blocks.radioButton') }, - { value: 'textInput', text: this.$t('blocks.textInput') }, - { value: 'textareaInput', text: this.$t('blocks.textareaInput') }, - { value: 'numberInput', text: this.$t('blocks.numberInput') }, - { value: 'switch', text: this.$t('blocks.switch') }, - { value: 'counter', text: this.$t('blocks.counter') } + { value: 'button', text: this.$t('_pages.blocks.button') }, + { value: 'radioButton', text: this.$t('_pages.blocks.radioButton') }, + { value: 'textInput', text: this.$t('_pages.blocks.textInput') }, + { value: 'textareaInput', text: this.$t('_pages.blocks.textareaInput') }, + { value: 'numberInput', text: this.$t('_pages.blocks.numberInput') }, + { value: 'switch', text: this.$t('_pages.blocks.switch') }, + { value: 'counter', text: this.$t('_pages.blocks.counter') } ] }, { label: this.$t('special-blocks'), items: [ - { value: 'if', text: this.$t('blocks.if') }, - { value: 'post', text: this.$t('blocks.post') } + { value: 'if', text: this.$t('_pages.blocks.if') }, + { value: 'post', text: this.$t('_pages.blocks.post') } ] }]; }, @@ -379,7 +385,7 @@ export default Vue.extend({ if (category) { category.items.push({ value: block.type, - text: this.$t(`script.blocks.${block.type}`) + text: this.$t(`_pages.script.blocks.${block.type}`) }); } else { list.push({ @@ -387,7 +393,7 @@ export default Vue.extend({ label: this.$t(`script.categories.${block.category}`), items: [{ value: block.type, - text: this.$t(`script.blocks.${block.type}`) + text: this.$t(`_pages.script.blocks.${block.type}`) }] }); } @@ -422,87 +428,89 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.gwbmwxkm - overflow hidden - background var(--face) - margin-bottom 16px - - &.round - border-radius 6px - - &.shadow - box-shadow 0 3px 8px rgba(0, 0, 0, 0.2) - - > header - background var(--faceHeader) - - > .title - z-index 1 - margin 0 - padding 0 16px - line-height 42px - font-size 0.9em - font-weight bold - color var(--faceHeaderText) - box-shadow 0 var(--lineWidth) rgba(#000, 0.07) +<style lang="scss" scoped> +.gwbmwxkm { + margin-bottom: var(--margin); - > [data-icon] - margin-right 6px + > header { + background: var(--faceHeader); - &:empty - display none + > .title { + z-index: 1; + margin: 0; + padding: 0 16px; + line-height: 42px; + font-size: 0.9em; + font-weight: bold; + color: var(--faceHeaderText); + box-shadow: 0 var(--lineWidth) rgba(#000, 0.07); - > .buttons - position absolute - z-index 2 - top 0 - right 0 - - > button - padding 0 - width 42px - font-size 0.9em - line-height 42px - color var(--faceTextButton) + > [data-icon] { + margin-right: 6px; + } - &:hover - color var(--faceTextButtonHover) + &:empty { + display: none; + } + } - &:active - color var(--faceTextButtonActive) + > .buttons { + position: absolute; + z-index: 2; + top: 0; + right: 0; - > section - padding 0 32px 32px 32px + > button { + padding: 0; + width: 42px; + font-size: 0.9em; + line-height: 42px; + } + } + } - @media (max-width 500px) - padding 0 16px 16px 16px + > section { + padding: 0 32px 32px 32px; - > .view - display inline-block - margin 16px 0 0 0 - font-size 14px + @media (max-width: 500px) { + padding: 0 16px 16px 16px; + } - > .content - margin-bottom 16px + > .view { + display: inline-block; + margin: 16px 0 0 0; + font-size: 14px; + } - > .eyeCatch - margin-bottom 16px + > .content { + margin-bottom: 16px; + } - > div - > img - max-width 100% + > .eyeCatch { + margin-bottom: 16px; -.qmuvgica - padding 32px + > div { + > img { + max-width: 100%; + } + } + } + } +} - @media (max-width 500px) - padding 16px +.qmuvgica { + padding: 32px; - > .variables - margin-bottom 16px + @media (max-width: 500px) { + padding: 16px; + } - > .add - margin-bottom 16px + > .variables { + margin-bottom: 16px; + } + > .add { + margin-bottom: 16px; + } +} </style> diff --git a/src/client/app/common/views/pages/page.vue b/src/client/pages/page.vue index d1c4c2be43..72c5101731 100644 --- a/src/client/app/common/views/pages/page.vue +++ b/src/client/pages/page.vue @@ -1,10 +1,14 @@ <template> -<x-page v-if="page" :page="page" :key="page.id" :show-footer="true"/> +<div class="xcukqgmh _panel"> + <portal to="avatar" v-if="page"><mk-avatar class="avatar" :user="page.user" :disable-preview="true"/></portal> + <portal to="title" v-if="page">{{ page.title || page.name }}</portal> + + <x-page v-if="page" :page="page" :key="page.id" :show-footer="true"/> +</div> </template> <script lang="ts"> import Vue from 'vue'; -import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; import XPage from '../components/page/page.vue'; export default Vue.extend({ @@ -52,12 +56,14 @@ export default Vue.extend({ username: this.username, }).then(page => { this.page = page; - this.$emit('init', { - title: this.page.title, - icon: faStickyNote - }); }); }, } }); </script> + +<style lang="scss" scoped> +.xcukqgmh { + +} +</style> diff --git a/src/client/pages/pages.vue b/src/client/pages/pages.vue new file mode 100644 index 0000000000..bee7d30a61 --- /dev/null +++ b/src/client/pages/pages.vue @@ -0,0 +1,78 @@ +<template> +<div> + <mk-container :body-togglable="true"> + <template #header><fa :icon="faEdit" fixed-width/>{{ $t('my-pages') }}</template> + <div class="rknalgpo my"> + <mk-button class="new" @click="create()"><fa :icon="faPlus"/></mk-button> + <mk-pagination :pagination="myPagesPagination" #default="{items}"> + <mk-page-preview v-for="page in items" class="ckltabjg" :page="page" :key="page.id"/> + </mk-pagination> + </div> + </mk-container> + + <mk-container :body-togglable="true"> + <template #header><fa :icon="faHeart" fixed-width/>{{ $t('liked-pages') }}</template> + <div class="rknalgpo"> + <mk-pagination :pagination="likedPagesPagination" #default="{items}"> + <mk-page-preview v-for="like in items" class="ckltabjg" :page="like.page" :key="like.page.id"/> + </mk-pagination> + </div> + </mk-container> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faPlus, faEdit } from '@fortawesome/free-solid-svg-icons'; +import { faStickyNote, faHeart } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../i18n'; +import MkPagePreview from '../components/page-preview.vue'; +import MkPagination from '../components/ui/pagination.vue'; +import MkButton from '../components/ui/button.vue'; +import MkContainer from '../components/ui/container.vue'; + +export default Vue.extend({ + i18n, + components: { + MkPagePreview, MkPagination, MkButton, MkContainer + }, + data() { + return { + myPagesPagination: { + endpoint: 'i/pages', + limit: 5, + }, + likedPagesPagination: { + endpoint: 'i/page-likes', + limit: 5, + }, + faStickyNote, faPlus, faEdit, faHeart + }; + }, + methods: { + create() { + this.$router.push(`/my/pages/new`); + } + } +}); +</script> + +<style lang="scss" scoped> +.rknalgpo { + padding: 16px; + + &.my .ckltabjg:first-child { + margin-top: 16px; + } + + .ckltabjg:not(:last-child) { + margin-bottom: 8px; + } + + @media (min-width: 500px) { + .ckltabjg:not(:last-child) { + margin-bottom: 16px; + } + } +} +</style> diff --git a/src/client/pages/search.vue b/src/client/pages/search.vue new file mode 100644 index 0000000000..c3e87c0d0c --- /dev/null +++ b/src/client/pages/search.vue @@ -0,0 +1,55 @@ +<template> +<div> + <portal to="icon"><fa :icon="faSearch"/></portal> + <portal to="title">{{ $t('searchWith', { q: $route.query.q }) }}</portal> + <x-notes ref="notes" :pagination="pagination" @before="before" @after="after"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faSearch } from '@fortawesome/free-solid-svg-icons'; +import Progress from '../scripts/loading'; +import XNotes from '../components/notes.vue'; + +export default Vue.extend({ + metaInfo() { + return { + title: this.$t('searchWith', { q: this.$route.query.q }) as string + }; + }, + + components: { + XNotes + }, + + data() { + return { + pagination: { + endpoint: 'notes/search', + limit: 10, + params: () => ({ + query: this.$route.query.q, + }) + }, + faSearch + }; + }, + + watch: { + $route() { + (this.$refs.notes as any).reload(); + } + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/src/client/pages/settings/2fa.vue b/src/client/pages/settings/2fa.vue new file mode 100644 index 0000000000..7163f2ece4 --- /dev/null +++ b/src/client/pages/settings/2fa.vue @@ -0,0 +1,264 @@ +<template> +<section class="_section"> + <div class="_title"><fa :icon="faLock"/> {{ $t('twoStepAuthentication') }}</div> + <div class="_content"> + <p v-if="!data && !$store.state.i.twoFactorEnabled"><mk-button @click="register">{{ $t('_2fa.registerDevice') }}</mk-button></p> + <template v-if="$store.state.i.twoFactorEnabled"> + <h2 class="heading">{{ $t('totp-header') }}</h2> + <p>{{ $t('already-registered') }}</p> + <mk-button @click="unregister">{{ $t('unregister') }}</mk-button> + + <template v-if="supportsCredentials"> + <hr class="totp-method-sep"> + + <h2 class="heading">{{ $t('security-key-header') }}</h2> + <p>{{ $t('security-key') }}</p> + <div class="key-list"> + <div class="key" v-for="key in $store.state.i.securityKeysList"> + <h3> + {{ key.name }} + </h3> + <div class="last-used"> + {{ $t('last-used') }} + <mk-time :time="key.lastUsed"/> + </div> + <mk-button @click="unregisterKey(key)"> + {{ $t('unregister') }} + </mk-button> + </div> + </div> + + <mk-switch v-model="usePasswordLessLogin" @change="updatePasswordLessLogin" v-if="$store.state.i.securityKeysList.length > 0"> + {{ $t('use-password-less-login') }} + </mk-switch> + + <mk-info warn v-if="registration && registration.error">{{ $t('something-went-wrong') }} {{ registration.error }}</mk-info> + <mk-button v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('register') }}</mk-button> + + <ol v-if="registration && !registration.error"> + <li v-if="registration.stage >= 0"> + {{ $t('activate-key') }} + <fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 0" /> + </li> + <li v-if="registration.stage >= 1"> + <mk-form :disabled="registration.stage != 1 || registration.saving"> + <mk-input v-model="keyName" :max="30"> + <span>{{ $t('security-key-name') }}</span> + </mk-input> + <mk-button @click="registerKey" :disabled="this.keyName.length == 0"> + {{ $t('register-security-key') }} + </mk-button> + <fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 1" /> + </mk-form> + </li> + </ol> + </template> + </template> + <div v-if="data && !$store.state.i.twoFactorEnabled"> + <ol style="margin: 0; padding: 0 0 0 1em;"> + <li> + <i18n path="_2fa.step1" tag="span"> + <a href="https://authy.com/" rel="noopener" target="_blank" place="a" style="color: var(--link);">Authy</a> + <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" place="b" style="color: var(--link);">Google Authenticator</a> + </i18n> + </li> + <li>{{ $t('_2fa.step2') }}<br><img :src="data.qr"></li> + <li>{{ $t('_2fa.step3') }}<br> + <mk-input v-model="token" type="number" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false">{{ $t('token') }}</mk-input> + <mk-button primary @click="submit">{{ $t('done') }}</mk-button> + </li> + </ol> + <mk-info>{{ $t('_2fa.step4') }}</mk-info> + </div> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faLock } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../../i18n'; +import { hostname } from '../../config'; +import { hexifyAB } from '../../scripts/2fa'; +import MkButton from '../../components/ui/button.vue'; +import MkInfo from '../../components/ui/info.vue'; +import MkInput from '../../components/ui/input.vue'; + +function stringifyAB(buffer) { + return String.fromCharCode.apply(null, new Uint8Array(buffer)); +} + +export default Vue.extend({ + i18n, + components: { + MkButton, MkInfo, MkInput + }, + data() { + return { + data: null, + supportsCredentials: !!navigator.credentials, + usePasswordLessLogin: this.$store.state.i.usePasswordLessLogin, + registration: null, + keyName: '', + token: null, + faLock + }; + }, + methods: { + register() { + this.$root.dialog({ + title: this.$t('password'), + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + this.$root.api('i/2fa/register', { + password: password + }).then(data => { + this.data = data; + }); + }); + }, + + unregister() { + this.$root.dialog({ + title: this.$t('password'), + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + this.$root.api('i/2fa/unregister', { + password: password + }).then(() => { + this.usePasswordLessLogin = false; + this.updatePasswordLessLogin(); + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + this.$store.state.i.twoFactorEnabled = false; + }); + }); + }, + + submit() { + this.$root.api('i/2fa/done', { + token: this.token + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + this.$store.state.i.twoFactorEnabled = true; + }).catch(e => { + this.$root.dialog({ + type: 'error', + iconOnly: true, autoClose: true + }); + }); + }, + + registerKey() { + this.registration.saving = true; + this.$root.api('i/2fa/key-done', { + password: this.registration.password, + name: this.keyName, + challengeId: this.registration.challengeId, + // we convert each 16 bits to a string to serialise + clientDataJSON: stringifyAB(this.registration.credential.response.clientDataJSON), + attestationObject: hexifyAB(this.registration.credential.response.attestationObject) + }).then(key => { + this.registration = null; + key.lastUsed = new Date(); + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }) + }, + + unregisterKey(key) { + this.$root.dialog({ + title: this.$t('password'), + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + return this.$root.api('i/2fa/remove-key', { + password, + credentialId: key.id + }).then(() => { + this.usePasswordLessLogin = false; + this.updatePasswordLessLogin(); + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }); + }); + }, + + addSecurityKey() { + this.$root.dialog({ + title: this.$t('password'), + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + this.$root.api('i/2fa/register-key', { + password + }).then(registration => { + this.registration = { + password, + challengeId: registration.challengeId, + stage: 0, + publicKeyOptions: { + challenge: Buffer.from( + registration.challenge + .replace(/\-/g, "+") + .replace(/_/g, "/"), + 'base64' + ), + rp: { + id: hostname, + name: 'Misskey' + }, + user: { + id: Uint8Array.from(this.$store.state.i.id, c => c.charCodeAt(0)), + name: this.$store.state.i.username, + displayName: this.$store.state.i.name, + }, + pubKeyCredParams: [{alg: -7, type: 'public-key'}], + timeout: 60000, + attestation: 'direct' + }, + saving: true + }; + return navigator.credentials.create({ + publicKey: this.registration.publicKeyOptions + }); + }).then(credential => { + this.registration.credential = credential; + this.registration.saving = false; + this.registration.stage = 1; + }).catch(err => { + console.warn('Error while registering?', err); + this.registration.error = err.message; + this.registration.stage = -1; + }); + }); + }, + updatePasswordLessLogin() { + this.$root.api('i/2fa/password-less', { + value: !!this.usePasswordLessLogin + }); + } + } +}); +</script> diff --git a/src/client/pages/settings/drive.vue b/src/client/pages/settings/drive.vue new file mode 100644 index 0000000000..d0c18a07e5 --- /dev/null +++ b/src/client/pages/settings/drive.vue @@ -0,0 +1,212 @@ +<template> +<section class="mk-settings-page-drive _section"> + <div class="_title"><fa :icon="faCloud"/> {{ $t('drive') }}</div> + <div class="_content"> + <mk-pagination :pagination="drivePagination" #default="{items}" class="drive" ref="drive"> + <div class="file" v-for="(file, i) in items" :key="file.id" :data-index="i" @click="selected = file" :class="{ selected: selected && (selected.id === file.id) }"> + <x-file-thumbnail class="thumbnail" :file="file" fit="cover"/> + <div class="body"> + <p class="name"> + <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> + <span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> + </p> + <footer> + <span class="type"><x-file-type-icon :type="file.type" class="icon"/>{{ file.type }}</span> + <span class="separator"></span> + <span class="data-size">{{ file.size | bytes }}</span> + <span class="separator"></span> + <span class="created-at"><fa :icon="faClock"/><mk-time :time="file.createdAt"/></span> + <template v-if="file.isSensitive"> + <span class="separator"></span> + <span class="nsfw"><fa :icon="faEyeSlash"/> {{ $t('nsfw') }}</span> + </template> + </footer> + </div> + </div> + </mk-pagination> + </div> + <div class="_footer"> + <mk-button primary inline :disabled="selected == null" @click="download()"><fa :icon="faDownload"/> {{ $t('download') }}</mk-button> + <mk-button inline :disabled="selected == null" @click="del()"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</mk-button> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faCloud, faDownload } from '@fortawesome/free-solid-svg-icons'; +import { faClock, faEyeSlash, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import XFileTypeIcon from '../../components/file-type-icon.vue'; +import XFileThumbnail from '../../components/drive-file-thumbnail.vue'; +import MkButton from '../../components/ui/button.vue'; +import MkPagination from '../../components/ui/pagination.vue'; +import i18n from '../../i18n'; + +export default Vue.extend({ + i18n, + + components: { + XFileTypeIcon, + XFileThumbnail, + MkPagination, + MkButton, + }, + + data() { + return { + selected: null, + connection: null, + drivePagination: { + endpoint: 'drive/files', + limit: 10, + }, + faCloud, faClock, faEyeSlash, faDownload, faTrashAlt + } + }, + + created() { + this.connection = this.$root.stream.useSharedConnection('drive'); + + this.connection.on('fileCreated', this.onStreamDriveFileCreated); + this.connection.on('fileUpdated', this.onStreamDriveFileUpdated); + this.connection.on('fileDeleted', this.onStreamDriveFileDeleted); + }, + + beforeDestroy() { + this.connection.dispose(); + }, + + methods: { + onStreamDriveFileCreated(file) { + this.$refs.drive.prepend(file); + }, + + onStreamDriveFileUpdated(file) { + // TODO + }, + + onStreamDriveFileDeleted(fileId) { + this.$refs.drive.remove(x => x.id === fileId); + }, + + download() { + window.open(this.selected.url, '_blank'); + }, + + async del() { + const { canceled } = await this.$root.dialog({ + type: 'warning', + text: this.$t('driveFileDeleteConfirm', { name: this.selected.name }), + showCancelButton: true + }); + if (canceled) return; + + this.$root.api('drive/files/delete', { + fileId: this.selected.id + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-settings-page-drive { + > ._content { + max-height: 350px; + overflow: auto; + + > .drive { + > .file { + display: grid; + margin: 0 auto; + grid-template-columns: 64px 1fr; + grid-column-gap: 10px; + cursor: pointer; + + &.selected { + background: var(--accent); + box-shadow: 0 0 0 8px var(--accent); + color: #fff; + } + + &:not(:last-child) { + margin-bottom: 16px; + } + + > .thumbnail { + width: 64px; + height: 64px; + } + + > .body { + display: block; + word-break: break-all; + padding-top: 4px; + + > .name { + display: block; + margin: 0; + padding: 0; + font-size: 0.9em; + font-weight: bold; + word-break: break-word; + + > .ext { + opacity: 0.5; + } + } + + > .tags { + display: block; + margin: 4px 0 0 0; + padding: 0; + list-style: none; + font-size: 0.5em; + + > .tag { + display: inline-block; + margin: 0 5px 0 0; + padding: 1px 5px; + border-radius: 2px; + } + } + + > footer { + display: block; + margin: 4px 0 0 0; + font-size: 0.7em; + + > .separator { + padding: 0 4px; + } + + > .type { + opacity: 0.7; + + > .icon { + margin-right: 4px; + } + } + + > .data-size { + opacity: 0.7; + } + + > .created-at { + opacity: 0.7; + + > [data-icon] { + margin-right: 2px; + } + } + + > .nsfw { + color: #bf4633; + } + } + } + } + } + } +} +</style> diff --git a/src/client/pages/settings/general.vue b/src/client/pages/settings/general.vue new file mode 100644 index 0000000000..6b63da742c --- /dev/null +++ b/src/client/pages/settings/general.vue @@ -0,0 +1,108 @@ +<template> +<section class="mk-settings-page-general _section"> + <div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div> + <div class="_content"> + <mk-input type="file" @change="onWallpaperChange" style="margin-top: 0;"> + <span>{{ $t('wallpaper') }}</span> + <template #icon><fa :icon="faImage"/></template> + <template #desc v-if="wallpaperUploading">{{ $t('uploading') }}<mk-ellipsis/></template> + </mk-input> + <mk-button primary :disabled="$store.state.settings.wallpaper == null" @click="delWallpaper()">{{ $t('removeWallpaper') }}</mk-button> + </div> + <div class="_content"> + <mk-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch"> + {{ $t('auto-watch') }}<template #desc>{{ $t('auto-watch-desc') }}</template> + </mk-switch> + </div> + <div class="_content"> + <mk-button @click="readAllNotifications">{{ $t('mark-as-read-all-notifications') }}</mk-button> + <mk-button @click="readAllUnreadNotes">{{ $t('mark-as-read-all-unread-notes') }}</mk-button> + <mk-button @click="readAllMessagingMessages">{{ $t('mark-as-read-all-talk-messages') }}</mk-button> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faImage, faCog } from '@fortawesome/free-solid-svg-icons'; +import MkInput from '../../components/ui/input.vue'; +import MkButton from '../../components/ui/button.vue'; +import MkSwitch from '../../components/ui/switch.vue'; +import i18n from '../../i18n'; +import { apiUrl } from '../../config'; + +export default Vue.extend({ + i18n, + + components: { + MkInput, + MkButton, + MkSwitch, + }, + + data() { + return { + wallpaperUploading: false, + faImage, faCog + } + }, + + computed: { + wallpaper: { + get() { return this.$store.state.settings.wallpaper; }, + set(value) { this.$store.dispatch('settings/set', { key: 'wallpaper', value }); } + }, + }, + + methods: { + onWallpaperChange([file]) { + this.wallpaperUploading = true; + + const data = new FormData(); + data.append('file', file); + data.append('i', this.$store.state.i.token); + + fetch(apiUrl + '/drive/files/create', { + method: 'POST', + body: data + }) + .then(response => response.json()) + .then(f => { + this.wallpaper = f.url; + this.wallpaperUploading = false; + document.documentElement.style.backgroundImage = `url(${this.$store.state.settings.wallpaper})`; + }) + .catch(e => { + this.wallpaperUploading = false; + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + + delWallpaper() { + this.wallpaper = null; + document.documentElement.style.backgroundImage = 'none'; + }, + + onChangeAutoWatch(v) { + this.$root.api('i/update', { + autoWatch: v + }); + }, + + readAllUnreadNotes() { + this.$root.api('i/read_all_unread_notes'); + }, + + readAllMessagingMessages() { + this.$root.api('i/read_all_messaging_messages'); + }, + + readAllNotifications() { + this.$root.api('notifications/mark_all_as_read'); + } + } +}); +</script> diff --git a/src/client/pages/settings/import-export.vue b/src/client/pages/settings/import-export.vue new file mode 100644 index 0000000000..5714aabbf6 --- /dev/null +++ b/src/client/pages/settings/import-export.vue @@ -0,0 +1,121 @@ +<template> +<section class="mk-settings-page-import-export _section"> + <div class="_title"><fa :icon="faBoxes"/> {{ $t('importAndExport') }}</div> + <div class="_content"> + <input ref="file" type="file" style="display: none;" @change="onChangeFile"/> + <mk-select v-model="exportTarget" style="margin-top: 0;"> + <option value="notes">{{ $t('_exportOrImport.allNotes') }}</option> + <option value="following">{{ $t('_exportOrImport.followingList') }}</option> + <option value="user-lists">{{ $t('_exportOrImport.userLists') }}</option> + <option value="mute">{{ $t('_exportOrImport.muteList') }}</option> + <option value="blocking">{{ $t('_exportOrImport.blockingList') }}</option> + </mk-select> + <mk-button inline @click="doExport()"><fa :icon="faDownload"/> {{ $t('export') }}</mk-button> + <mk-button inline @click="doImport()" :disabled="!['following', 'user-lists'].includes(exportTarget)"><fa :icon="faUpload"/> {{ $t('import') }}</mk-button> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faDownload, faUpload, faBoxes } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '../../components/ui/button.vue'; +import MkSelect from '../../components/ui/select.vue'; +import i18n from '../../i18n'; +import { apiUrl } from '../../config'; + +export default Vue.extend({ + i18n, + + components: { + MkButton, + MkSelect, + }, + + data() { + return { + exportTarget: 'notes', + faDownload, faUpload, faBoxes + } + }, + + methods: { + doExport() { + this.$root.api( + this.exportTarget == 'notes' ? 'i/export-notes' : + this.exportTarget == 'following' ? 'i/export-following' : + this.exportTarget == 'blocking' ? 'i/export-blocking' : + this.exportTarget == 'user-lists' ? 'i/export-user-lists' : + null, {}) + .then(() => { + this.$root.dialog({ + type: 'info', + text: this.$t('exportRequested') + }); + }).catch((e: any) => { + this.$root.dialog({ + type: 'error', + text: e.message + }); + }); + }, + + doImport() { + (this.$refs.file as any).click(); + }, + + onChangeFile() { + const [file] = Array.from((this.$refs.file as any).files); + + const data = new FormData(); + data.append('file', file); + data.append('i', this.$store.state.i.token); + + const dialog = this.$root.dialog({ + type: 'waiting', + text: this.$t('uploading') + '...', + showOkButton: false, + showCancelButton: false, + cancelableByBgClick: false + }); + + fetch(apiUrl + '/drive/files/create', { + method: 'POST', + body: data + }) + .then(response => response.json()) + .then(f => { + this.reqImport(f); + }) + .catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }) + .finally(() => { + dialog.close(); + }); + }, + + reqImport(file) { + this.$root.api( + this.exportTarget == 'following' ? 'i/import-following' : + this.exportTarget == 'user-lists' ? 'i/import-user-lists' : + null, { + fileId: file.id + }).then(() => { + this.$root.dialog({ + type: 'info', + text: this.$t('importRequested') + }); + }).catch((e: any) => { + this.$root.dialog({ + type: 'error', + text: e.message + }); + }); + } + } +}); +</script> diff --git a/src/client/pages/settings/index.vue b/src/client/pages/settings/index.vue new file mode 100644 index 0000000000..1a00c65760 --- /dev/null +++ b/src/client/pages/settings/index.vue @@ -0,0 +1,94 @@ +<template> +<div class="mk-settings-page"> + <portal to="icon"><fa :icon="faCog"/></portal> + <portal to="title">{{ $t('settings') }}</portal> + + <x-profile-setting/> + <x-privacy-setting/> + <x-reaction-setting/> + <x-theme/> + <x-import-export/> + <x-drive/> + <x-general/> + <x-mute-block/> + <x-security/> + <x-2fa/> + <x-integration/> + + <mk-button @click="cacheClear()" primary class="cacheClear">{{ $t('cacheClear') }}</mk-button> + <mk-button @click="$root.signout()" primary class="logout">{{ $t('logout') }}</mk-button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faCog } from '@fortawesome/free-solid-svg-icons'; +import XProfileSetting from './profile.vue'; +import XPrivacySetting from './privacy.vue'; +import XImportExport from './import-export.vue'; +import XDrive from './drive.vue'; +import XGeneral from './general.vue'; +import XReactionSetting from './reaction.vue'; +import XMuteBlock from './mute-block.vue'; +import XSecurity from './security.vue'; +import XTheme from './theme.vue'; +import X2fa from './2fa.vue'; +import XIntegration from './integration.vue'; +import MkButton from '../../components/ui/button.vue'; + +export default Vue.extend({ + metaInfo() { + return { + title: this.$t('settings') as string + }; + }, + + components: { + XProfileSetting, + XPrivacySetting, + XImportExport, + XDrive, + XGeneral, + XReactionSetting, + XMuteBlock, + XSecurity, + XTheme, + X2fa, + XIntegration, + MkButton, + }, + + data() { + return { + faCog + } + }, + + methods: { + cacheClear() { + // Clear cache (service worker) + try { + navigator.serviceWorker.controller.postMessage('clear'); + + navigator.serviceWorker.getRegistrations().then(registrations => { + for (const registration of registrations) registration.unregister(); + }); + } catch (e) { + console.error(e); + } + + // Force reload + location.reload(true); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-settings-page { + > .logout, + > .cacheClear { + margin: 8px auto; + } +} +</style> diff --git a/src/client/pages/settings/integration.vue b/src/client/pages/settings/integration.vue new file mode 100644 index 0000000000..b156e13027 --- /dev/null +++ b/src/client/pages/settings/integration.vue @@ -0,0 +1,122 @@ +<template> +<section class="_section" v-if="enableTwitterIntegration || enableDiscordIntegration || enableGithubIntegration"> + <div class="_title"><fa :icon="faShareAlt"/> {{ $t('integration') }}</div> + <div class="_content" v-if="enableTwitterIntegration"> + <header><fa :icon="faTwitter"/> Twitter</header> + <p v-if="$store.state.i.twitter">{{ $t('connectedTo') }}: <a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p> + <mk-button v-if="$store.state.i.twitter" @click="disconnectTwitter">{{ $t('disconnectSerice') }}</mk-button> + <mk-button v-else @click="connectTwitter">{{ $t('connectSerice') }}</mk-button> + </div> + + <div class="_content" v-if="enableDiscordIntegration"> + <header><fa :icon="faDiscord"/> Discord</header> + <p v-if="$store.state.i.discord">{{ $t('connectedTo') }}: <a :href="`https://discordapp.com/users/${$store.state.i.discord.id}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.discord.username }}#{{ $store.state.i.discord.discriminator }}</a></p> + <mk-button v-if="$store.state.i.discord" @click="disconnectDiscord">{{ $t('disconnectSerice') }}</mk-button> + <mk-button v-else @click="connectDiscord">{{ $t('connectSerice') }}</mk-button> + </div> + + <div class="_content" v-if="enableGithubIntegration"> + <header><fa :icon="faGithub"/> GitHub</header> + <p v-if="$store.state.i.github">{{ $t('connectedTo') }}: <a :href="`https://github.com/${$store.state.i.github.login}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.github.login }}</a></p> + <mk-button v-if="$store.state.i.github" @click="disconnectGithub">{{ $t('disconnectSerice') }}</mk-button> + <mk-button v-else @click="connectGithub">{{ $t('connectSerice') }}</mk-button> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faShareAlt } from '@fortawesome/free-solid-svg-icons'; +import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons'; +import i18n from '../../i18n'; +import { apiUrl } from '../../config'; +import MkButton from '../../components/ui/button.vue'; + +export default Vue.extend({ + i18n, + + components: { + MkButton + }, + + data() { + return { + apiUrl, + twitterForm: null, + discordForm: null, + githubForm: null, + enableTwitterIntegration: false, + enableDiscordIntegration: false, + enableGithubIntegration: false, + faShareAlt, faTwitter, faDiscord, faGithub + }; + }, + + created() { + this.$root.getMeta().then(meta => { + this.enableTwitterIntegration = meta.enableTwitterIntegration; + this.enableDiscordIntegration = meta.enableDiscordIntegration; + this.enableGithubIntegration = meta.enableGithubIntegration; + }); + }, + + mounted() { + if (!document.cookie.match(/i=(\w+)/)) { + document.cookie = `i=${this.$store.state.i.token}; path=/;` + + ` domain=${document.location.hostname}; max-age=31536000;` + + (document.location.protocol.startsWith('https') ? ' secure' : ''); + } + 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> diff --git a/src/client/pages/settings/mute-block.vue b/src/client/pages/settings/mute-block.vue new file mode 100644 index 0000000000..109b33d4f5 --- /dev/null +++ b/src/client/pages/settings/mute-block.vue @@ -0,0 +1,76 @@ +<template> +<section class="mk-settings-page-mute-block _section"> + <div class="_title"><fa :icon="faBan"/> {{ $t('muteAndBlock') }}</div> + <div class="_content"> + <span>{{ $t('mutedUsers') }}</span> + <mk-pagination :pagination="mutingPagination" class="muting"> + <template #empty><span>{{ $t('noUsers') }}</span></template> + <template #default="{items}"> + <div class="user" v-for="(mute, i) in items" :key="mute.id" :data-index="i"> + <router-link class="name" :to="mute.mutee | userPage"> + <mk-acct :user="mute.mutee"/> + </router-link> + </div> + </template> + </mk-pagination> + </div> + <div class="_content"> + <span>{{ $t('blockedUsers') }}</span> + <mk-pagination :pagination="blockingPagination" class="blocking"> + <template #empty><span>{{ $t('noUsers') }}</span></template> + <template #default="{items}"> + <div class="user" v-for="(block, i) in items" :key="block.id" :data-index="i"> + <router-link class="name" :to="block.blockee | userPage"> + <mk-acct :user="block.blockee"/> + </router-link> + </div> + </template> + </mk-pagination> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faBan } from '@fortawesome/free-solid-svg-icons'; +import MkPagination from '../../components/ui/pagination.vue'; +import i18n from '../../i18n'; + +export default Vue.extend({ + i18n, + + components: { + MkPagination, + }, + + data() { + return { + mutingPagination: { + endpoint: 'mute/list', + limit: 10, + }, + blockingPagination: { + endpoint: 'blocking/list', + limit: 10, + }, + faBan + } + }, +}); +</script> + +<style lang="scss" scoped> +.mk-settings-page-mute-block { + > ._content { + max-height: 350px; + overflow: auto; + + > .muting, + > .blocking { + > .empty { + opacity: 0.5 !important; + } + } + } +} +</style> diff --git a/src/client/pages/settings/privacy.vue b/src/client/pages/settings/privacy.vue new file mode 100644 index 0000000000..0fc67d5b7d --- /dev/null +++ b/src/client/pages/settings/privacy.vue @@ -0,0 +1,69 @@ +<template> +<section class="mk-settings-page-privacy _section"> + <div class="_title"><fa :icon="faLock"/> {{ $t('privacy') }}</div> + <div class="_content"> + <mk-switch v-model="isLocked" @change="save()">{{ $t('makeFollowManuallyApprove') }}</mk-switch> + <mk-switch v-model="autoAcceptFollowed" :disabled="!isLocked" @change="save()">{{ $t('autoAcceptFollowed') }}</mk-switch> + </div> + <div class="_content"> + <mk-select v-model="defaultNoteVisibility" style="margin-top: 8px;"> + <template #label>{{ $t('defaultNoteVisibility') }}</template> + <option value="public">{{ $t('_visibility.public') }}</option> + <option value="followers">{{ $t('_visibility.followers') }}</option> + <option value="specified">{{ $t('_visibility.specified') }}</option> + </mk-select> + <mk-switch v-model="rememberNoteVisibility" @change="save()">{{ $t('rememberNoteVisibility') }}</mk-switch> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faLock } from '@fortawesome/free-solid-svg-icons'; +import MkSelect from '../../components/ui/select.vue'; +import MkSwitch from '../../components/ui/switch.vue'; +import i18n from '../../i18n'; + +export default Vue.extend({ + i18n, + + components: { + MkSelect, + MkSwitch, + }, + + data() { + return { + isLocked: false, + autoAcceptFollowed: false, + faLock + } + }, + + computed: { + defaultNoteVisibility: { + get() { return this.$store.state.settings.defaultNoteVisibility; }, + set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); } + }, + + rememberNoteVisibility: { + get() { return this.$store.state.settings.rememberNoteVisibility; }, + set(value) { this.$store.dispatch('settings/set', { key: 'rememberNoteVisibility', value }); } + }, + }, + + created() { + this.isLocked = this.$store.state.i.isLocked; + this.autoAcceptFollowed = this.$store.state.i.autoAcceptFollowed; + }, + + methods: { + save() { + this.$root.api('i/update', { + isLocked: !!this.isLocked, + autoAcceptFollowed: !!this.autoAcceptFollowed, + }); + } + } +}); +</script> diff --git a/src/client/pages/settings/profile.vue b/src/client/pages/settings/profile.vue new file mode 100644 index 0000000000..e6219c2d56 --- /dev/null +++ b/src/client/pages/settings/profile.vue @@ -0,0 +1,246 @@ +<template> +<section class="mk-settings-page-profile _section"> + <div class="_title"><fa :icon="faUser"/> {{ $t('profile') }}<small style="display: block; font-weight: normal; opacity: 0.6;">@{{ $store.state.i.username }}@{{ host }}</small></div> + <div class="_content"> + <mk-input v-model="name" :max="30"> + <span>{{ $t('_profile.name') }}</span> + </mk-input> + + <mk-textarea v-model="description" :max="500"> + <span>{{ $t('_profile.description') }}</span> + <template #desc>{{ $t('_profile.youCanIncludeHashtags') }}</template> + </mk-textarea> + + <mk-input v-model="location"> + <span>{{ $t('location') }}</span> + <template #prefix><fa :icon="faMapMarkerAlt"/></template> + </mk-input> + + <mk-input v-model="birthday" type="date"> + <template #title>{{ $t('birthday') }}</template> + <template #prefix><fa :icon="faBirthdayCake"/></template> + </mk-input> + + <mk-input type="file" @change="onAvatarChange"> + <span>{{ $t('avatar') }}</span> + <template #icon><fa :icon="faImage"/></template> + <template #desc v-if="avatarUploading">{{ $t('uploading') }}<mk-ellipsis/></template> + </mk-input> + + <mk-input type="file" @change="onBannerChange"> + <span>{{ $t('banner') }}</span> + <template #icon><fa :icon="faImage"/></template> + <template #desc v-if="bannerUploading">{{ $t('uploading') }}<mk-ellipsis/></template> + </mk-input> + + <details class="fields"> + <summary>{{ $t('_profile.metadata') }}</summary> + <div class="row"> + <mk-input v-model="fieldName0">{{ $t('_profile.metadataLabel') }}</mk-input> + <mk-input v-model="fieldValue0">{{ $t('_profile.metadataContent') }}</mk-input> + </div> + <div class="row"> + <mk-input v-model="fieldName1">{{ $t('_profile.metadataLabel') }}</mk-input> + <mk-input v-model="fieldValue1">{{ $t('_profile.metadataContent') }}</mk-input> + </div> + <div class="row"> + <mk-input v-model="fieldName2">{{ $t('_profile.metadataLabel') }}</mk-input> + <mk-input v-model="fieldValue2">{{ $t('_profile.metadataContent') }}</mk-input> + </div> + <div class="row"> + <mk-input v-model="fieldName3">{{ $t('_profile.metadataLabel') }}</mk-input> + <mk-input v-model="fieldValue3">{{ $t('_profile.metadataContent') }}</mk-input> + </div> + </details> + + <mk-switch v-model="isBot">{{ $t('flagAsBot') }}</mk-switch> + <mk-switch v-model="isCat">{{ $t('flagAsCat') }}</mk-switch> + </div> + <div class="_footer"> + <mk-button @click="save(true)" primary><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faUnlockAlt, faCogs, faImage, faUser, faMapMarkerAlt, faBirthdayCake } from '@fortawesome/free-solid-svg-icons'; +import { faSave } from '@fortawesome/free-regular-svg-icons'; +import MkButton from '../../components/ui/button.vue'; +import MkInput from '../../components/ui/input.vue'; +import MkTextarea from '../../components/ui/textarea.vue'; +import MkSwitch from '../../components/ui/switch.vue'; +import i18n from '../../i18n'; +import { apiUrl, host } from '../../config'; + +export default Vue.extend({ + i18n, + + components: { + MkButton, + MkInput, + MkTextarea, + MkSwitch, + }, + + data() { + return { + host, + name: null, + description: null, + birthday: null, + location: null, + fieldName0: null, + fieldValue0: null, + fieldName1: null, + fieldValue1: null, + fieldName2: null, + fieldValue2: null, + fieldName3: null, + fieldValue3: null, + avatarId: null, + bannerId: null, + isBot: false, + isCat: false, + saving: false, + avatarUploading: false, + bannerUploading: false, + faSave, faUnlockAlt, faCogs, faImage, faUser, faMapMarkerAlt, faBirthdayCake + } + }, + + created() { + this.name = this.$store.state.i.name; + this.description = this.$store.state.i.description; + this.location = this.$store.state.i.location; + this.birthday = this.$store.state.i.birthday; + this.avatarId = this.$store.state.i.avatarId; + this.bannerId = this.$store.state.i.bannerId; + this.isBot = this.$store.state.i.isBot; + this.isCat = this.$store.state.i.isCat; + + this.fieldName0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].name : null; + this.fieldValue0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].value : null; + this.fieldName1 = this.$store.state.i.fields[1] ? this.$store.state.i.fields[1].name : null; + this.fieldValue1 = this.$store.state.i.fields[1] ? this.$store.state.i.fields[1].value : null; + this.fieldName2 = this.$store.state.i.fields[2] ? this.$store.state.i.fields[2].name : null; + this.fieldValue2 = this.$store.state.i.fields[2] ? this.$store.state.i.fields[2].value : null; + this.fieldName3 = this.$store.state.i.fields[3] ? this.$store.state.i.fields[3].name : null; + this.fieldValue3 = this.$store.state.i.fields[3] ? this.$store.state.i.fields[3].value : null; + }, + + methods: { + onAvatarChange([file]) { + this.avatarUploading = true; + + const data = new FormData(); + data.append('file', file); + data.append('i', this.$store.state.i.token); + + fetch(apiUrl + '/drive/files/create', { + method: 'POST', + body: data + }) + .then(response => response.json()) + .then(f => { + this.avatarId = f.id; + this.avatarUploading = false; + }) + .catch(e => { + this.avatarUploading = false; + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + + onBannerChange([file]) { + this.bannerUploading = true; + + const data = new FormData(); + data.append('file', file); + data.append('i', this.$store.state.i.token); + + fetch(apiUrl + '/drive/files/create', { + method: 'POST', + body: data + }) + .then(response => response.json()) + .then(f => { + this.bannerId = f.id; + this.bannerUploading = false; + }) + .catch(e => { + this.bannerUploading = false; + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + + save(notify) { + const fields = [ + { name: this.fieldName0, value: this.fieldValue0 }, + { name: this.fieldName1, value: this.fieldValue1 }, + { name: this.fieldName2, value: this.fieldValue2 }, + { name: this.fieldName3, value: this.fieldValue3 }, + ]; + + this.saving = true; + + this.$root.api('i/update', { + name: this.name || null, + description: this.description || null, + location: this.location || null, + birthday: this.birthday || null, + avatarId: this.avatarId || undefined, + bannerId: this.bannerId || undefined, + fields, + isBot: !!this.isBot, + isCat: !!this.isCat, + }).then(i => { + this.saving = false; + this.$store.state.i.avatarId = i.avatarId; + this.$store.state.i.avatarUrl = i.avatarUrl; + this.$store.state.i.bannerId = i.bannerId; + this.$store.state.i.bannerUrl = i.bannerUrl; + + if (notify) { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + } + }).catch(err => { + this.saving = false; + this.$root.dialog({ + type: 'error', + text: err.id + }); + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-settings-page-profile { + > ._content { + > *:first-child { + margin-top: 0; + } + + > .fields { + > .row { + > * { + display: inline-block; + width: 50%; + margin-bottom: 0; + } + } + } + } +} +</style> diff --git a/src/client/pages/settings/reaction.vue b/src/client/pages/settings/reaction.vue new file mode 100644 index 0000000000..310237b5fd --- /dev/null +++ b/src/client/pages/settings/reaction.vue @@ -0,0 +1,62 @@ +<template> +<section class="mk-settings-page-reaction _section"> + <div class="_title"><fa :icon="faLaugh"/> {{ $t('reaction') }}</div> + <div class="_content"> + <mk-textarea v-model="reactions" style="margin-top: 16px;">{{ $t('reaction') }}<template #desc>{{ $t('reactionSettingDescription') }}</template></mk-textarea> + </div> + <div class="_footer"> + <mk-button @click="save()" primary inline :disabled="!changed"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <mk-button inline @click="preview"><fa :icon="faEye"/> {{ $t('preview') }}</mk-button> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faLaugh, faSave, faEye } from '@fortawesome/free-regular-svg-icons'; +import MkTextarea from '../../components/ui/textarea.vue'; +import MkButton from '../../components/ui/button.vue'; +import MkReactionPicker from '../../components/reaction-picker.vue'; +import i18n from '../../i18n'; + +export default Vue.extend({ + i18n, + + components: { + MkTextarea, + MkButton, + }, + + data() { + return { + reactions: this.$store.state.settings.reactions.join('\n'), + changed: false, + faLaugh, faSave, faEye + } + }, + + watch: { + reactions() { + this.changed = true; + } + }, + + methods: { + save() { + this.$store.dispatch('settings/set', { key: 'reactions', value: this.reactions.trim().split('\n') }); + this.changed = false; + }, + + preview(ev) { + const picker = this.$root.new(MkReactionPicker, { + source: ev.currentTarget || ev.target, + reactions: this.reactions.trim().split('\n'), + showFocus: false, + }); + picker.$once('chosen', reaction => { + picker.close(); + }); + } + } +}); +</script> diff --git a/src/client/pages/settings/security.vue b/src/client/pages/settings/security.vue new file mode 100644 index 0000000000..ecf9c01dd5 --- /dev/null +++ b/src/client/pages/settings/security.vue @@ -0,0 +1,87 @@ +<template> +<section class="_section"> + <div class="_title"><fa :icon="faLock"/> {{ $t('password') }}</div> + <div class="_content"> + <mk-button primary @click="change()">{{ $t('changePassword') }}</mk-button> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faLock } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '../../components/ui/button.vue'; +import i18n from '../../i18n'; + +export default Vue.extend({ + i18n, + + components: { + MkButton, + }, + + data() { + return { + faLock + } + }, + + methods: { + async change() { + const { canceled: canceled1, result: currentPassword } = await this.$root.dialog({ + title: this.$t('currentPassword'), + input: { + type: 'password' + } + }); + if (canceled1) return; + + const { canceled: canceled2, result: newPassword } = await this.$root.dialog({ + title: this.$t('newPassword'), + input: { + type: 'password' + } + }); + if (canceled2) return; + + const { canceled: canceled3, result: newPassword2 } = await this.$root.dialog({ + title: this.$t('newPasswordRetype'), + input: { + type: 'password' + } + }); + if (canceled3) return; + + if (newPassword !== newPassword2) { + this.$root.dialog({ + type: 'error', + text: this.$t('retypedNotMatch') + }); + return; + } + + const dialog = this.$root.dialog({ + type: 'waiting', + iconOnly: true + }); + + this.$root.api('i/change-password', { + currentPassword, + newPassword + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }).finally(() => { + dialog.close(); + }); + } + } +}); +</script> diff --git a/src/client/pages/settings/theme.vue b/src/client/pages/settings/theme.vue new file mode 100644 index 0000000000..71628ab2b9 --- /dev/null +++ b/src/client/pages/settings/theme.vue @@ -0,0 +1,76 @@ +<template> +<section class="mk-settings-page-theme _section"> + <div class="_title"><fa :icon="faPalette"/> {{ $t('theme') }}</div> + <div class="_content"> + <mk-select v-model="theme" :placeholder="$t('theme')"> + <template #label>{{ $t('theme') }}</template> + <optgroup :label="$t('lightThemes')"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$t('darkThemes')"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </mk-select> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faPalette } from '@fortawesome/free-solid-svg-icons'; +import MkInput from '../../components/ui/input.vue'; +import MkButton from '../../components/ui/button.vue'; +import MkSelect from '../../components/ui/select.vue'; +import i18n from '../../i18n'; +import { Theme, builtinThemes, applyTheme } from '../../theme'; + +export default Vue.extend({ + i18n, + + components: { + MkInput, + MkButton, + MkSelect, + }, + + data() { + return { + wallpaperUploading: false, + faPalette + } + }, + + computed: { + themes(): Theme[] { + return builtinThemes.concat(this.$store.state.device.themes); + }, + + installedThemes(): Theme[] { + return this.$store.state.device.themes; + }, + + darkThemes(): Theme[] { + return this.themes.filter(t => t.base == 'dark' || t.kind == 'dark'); + }, + + lightThemes(): Theme[] { + return this.themes.filter(t => t.base == 'light' || t.kind == 'light'); + }, + + theme: { + get() { return this.$store.state.device.theme; }, + set(value) { this.$store.commit('device/set', { key: 'theme', value }); } + }, + }, + + watch: { + theme() { + applyTheme(this.themes.find(x => x.id === this.theme)); + } + }, + + methods: { + + } +}); +</script> diff --git a/src/client/pages/tag.vue b/src/client/pages/tag.vue new file mode 100644 index 0000000000..f53f3c5ca1 --- /dev/null +++ b/src/client/pages/tag.vue @@ -0,0 +1,49 @@ +<template> +<x-notes ref="notes" :pagination="pagination" @before="before" @after="after"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../scripts/loading'; +import XNotes from '../components/notes.vue'; + +export default Vue.extend({ + metaInfo() { + return { + title: '#' + this.$route.params.tag + }; + }, + + components: { + XNotes + }, + + data() { + return { + pagination: { + endpoint: 'notes/search-by-tag', + limit: 10, + params: () => ({ + tag: this.$route.params.tag, + }) + } + }; + }, + + watch: { + $route() { + (this.$refs.notes as any).reload(); + } + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/src/client/pages/user/follow-list.vue b/src/client/pages/user/follow-list.vue new file mode 100644 index 0000000000..faaee3b107 --- /dev/null +++ b/src/client/pages/user/follow-list.vue @@ -0,0 +1,140 @@ +<template> +<mk-pagination :pagination="pagination" #default="{items}" class="mk-following-or-followers" ref="list"> + <div class="user _panel" v-for="(user, i) in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" :data-index="i"> + <mk-avatar class="avatar" :user="user"/> + <div class="body"> + <div class="name"> + <router-link class="name" :to="user | userPage" v-user-preview="user.id"><mk-user-name :user="user"/></router-link> + <p class="acct">@{{ user | acct }}</p> + </div> + <div class="description" v-if="user.description" :title="user.description"> + <mfm :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :plain="true" :nowrap="true"/> + </div> + <x-follow-button class="koudoku-button" v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" mini/> + </div> + </div> +</mk-pagination> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import parseAcct from '../../../misc/acct/parse'; +import i18n from '../../i18n'; +import XFollowButton from '../../components/follow-button.vue'; +import MkPagination from '../../components/ui/pagination.vue'; + +export default Vue.extend({ + i18n, + + components: { + MkPagination, + XFollowButton, + }, + + props: { + type: { + type: String, + required: true + } + }, + + data() { + return { + pagination: { + endpoint: () => this.type === 'following' ? 'users/following' : 'users/followers', + limit: 20, + params: { + ...parseAcct(this.$route.params.user), + } + }, + }; + }, + + watch: { + type() { + this.$refs.list.reload(); + }, + + '$route'() { + this.$refs.list.reload(); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-following-or-followers { + > .user { + display: flex; + padding: 16px; + + > .avatar { + display: block; + flex-shrink: 0; + margin: 0 12px 0 0; + width: 42px; + height: 42px; + border-radius: 8px; + } + + > .body { + display: flex; + width: calc(100% - 54px); + position: relative; + + > .name { + width: 45%; + + @media (max-width: 500px) { + width: 100%; + } + + > .name, + > .acct { + display: block; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + margin: 0; + } + + > .name { + font-size: 16px; + line-height: 24px; + } + + > .acct { + font-size: 15px; + line-height: 16px; + opacity: 0.7; + } + } + + > .description { + width: 55%; + line-height: 42px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.7; + font-size: 14px; + padding-right: 40px; + padding-left: 8px; + box-sizing: border-box; + + @media (max-width: 500px) { + display: none; + } + } + + > .koudoku-button { + position: absolute; + top: 0; + bottom: 0; + right: 0; + margin: auto 0; + } + } + } +} +</style> diff --git a/src/client/app/common/views/components/activity.vue b/src/client/pages/user/index.activity.vue index a958616943..29dcca0664 100644 --- a/src/client/app/common/views/components/activity.vue +++ b/src/client/pages/user/index.activity.vue @@ -17,7 +17,7 @@ export default Vue.extend({ limit: { type: Number, required: false, - default: 21 + default: 40 } }, data() { @@ -69,7 +69,7 @@ export default Vue.extend({ }, plotOptions: { bar: { - columnWidth: '80%' + columnWidth: '40%' } }, dataLabels: { diff --git a/src/client/app/mobile/views/pages/user/home.photos.vue b/src/client/pages/user/index.photos.vue index b5547c916f..cd29254f48 100644 --- a/src/client/app/mobile/views/pages/user/home.photos.vue +++ b/src/client/pages/user/index.photos.vue @@ -1,6 +1,6 @@ <template> -<div class="root photos"> - <p class="initializing" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p> +<div class="ujigsodd"> + <mk-loading v-if="fetching"/> <div class="stream" v-if="!fetching && images.length > 0"> <a v-for="(image, i) in images" :key="i" class="img" @@ -14,11 +14,11 @@ <script lang="ts"> import Vue from 'vue'; -import i18n from '../../../../i18n'; -import { getStaticImageUrl } from '../../../../common/scripts/get-static-image-url'; +import i18n from '../../i18n'; +import { getStaticImageUrl } from '../../scripts/get-static-image-url'; export default Vue.extend({ - i18n: i18n('mobile/views/pages/user/home.photos.vue'), + i18n, props: ['user'], data() { return { @@ -63,37 +63,36 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.root.photos +<style lang="scss" scoped> +.ujigsodd { - > .stream - display -webkit-flex - display -moz-flex - display -ms-flex - display flex - justify-content center - flex-wrap wrap - padding 8px + > .stream { + display: flex; + justify-content: center; + flex-wrap: wrap; + padding: 8px; - > .img - flex 1 1 33% - width 33% - height 90px - background-position center center - background-size cover - background-clip content-box - border solid 2px transparent - border-radius 4px + > .img { + flex: 1 1 33%; + width: 33%; + height: 90px; + box-sizing: border-box; + background-position: center center; + background-size: cover; + background-clip: content-box; + border: solid 2px transparent; + border-radius: 4px; + } + } - > .initializing - > .empty - margin 0 - padding 16px - text-align center - color var(--text) - - > i - margin-right 4px + > .empty { + margin: 0; + padding: 16px; + text-align: center; + > i { + margin-right: 4px; + } + } +} </style> - diff --git a/src/client/pages/user/index.timeline.vue b/src/client/pages/user/index.timeline.vue new file mode 100644 index 0000000000..1878a9b1f3 --- /dev/null +++ b/src/client/pages/user/index.timeline.vue @@ -0,0 +1,79 @@ +<template> +<div class="kjeftjfm"> + <div class="with"> + <button class="_button" @click="with_ = null" :class="{ active: with_ === null }">{{ $t('notes') }}</button> + <button class="_button" @click="with_ = 'replies'" :class="{ active: with_ === 'replies' }">{{ $t('notesAndReplies') }}</button> + <button class="_button" @click="with_ = 'files'" :class="{ active: with_ === 'files' }">{{ $t('withFiles') }}</button> + </div> + <x-notes ref="timeline" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNotes from '../../components/notes.vue'; + +export default Vue.extend({ + components: { + XNotes + }, + + props: { + user: { + type: Object, + required: true, + }, + }, + + watch: { + user() { + this.$refs.timeline.reload(); + }, + + with_() { + this.$refs.timeline.reload(); + }, + }, + + data() { + return { + date: null, + with_: null, + pagination: { + endpoint: 'users/notes', + limit: 10, + params: init => ({ + userId: this.user.id, + includeReplies: this.with_ === 'replies', + withFiles: this.with_ === 'files', + untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), + }) + } + }; + }, +}); +</script> + +<style lang="scss" scoped> +.kjeftjfm { + > .with { + display: flex; + margin-bottom: var(--margin); + + @media (max-width: 500px) { + font-size: 80%; + } + + > button { + flex: 1; + padding: 11px 8px 8px 8px; + border-bottom: solid 3px transparent; + + &.active { + color: var(--accent); + border-bottom-color: var(--accent); + } + } + } +} +</style> diff --git a/src/client/pages/user/index.vue b/src/client/pages/user/index.vue new file mode 100644 index 0000000000..7bf45621b3 --- /dev/null +++ b/src/client/pages/user/index.vue @@ -0,0 +1,476 @@ +<template> +<div class="mk-user-page" v-if="user"> + <portal to="title" v-if="user"><mk-user-name :user="user" :nowrap="false" class="name"/></portal> + <portal to="avatar" v-if="user"><mk-avatar class="avatar" :user="user" :disable-preview="true"/></portal> + + <div class="remote-caution _panel" v-if="user.host != null"><fa :icon="faExclamationTriangle" style="margin-right: 8px;"/>{{ $t('remoteUserCaution') }}<a :href="user.url" rel="nofollow noopener" target="_blank">{{ $t('showOnRemote') }}</a></div> + <transition name="zoom" mode="out-in" appear> + <div class="profile _panel" :key="user.id"> + <div class="banner-container" :style="style"> + <div class="banner" ref="banner" :style="style"></div> + <div class="fade"></div> + <div class="title"> + <mk-user-name class="name" :user="user" :nowrap="true"/> + <div class="bottom"> + <span class="username"><mk-acct :user="user" :detail="true" /></span> + <span v-if="user.isAdmin" :title="$t('isAdmin')"><fa :icon="faBookmark"/></span> + <span v-if="user.isLocked" :title="$t('isLocked')"><fa :icon="faLock"/></span> + <span v-if="user.isBot" :title="$t('isBot')"><fa :icon="faRobot"/></span> + </div> + </div> + <span class="followed" v-if="$store.getters.isSignedIn && $store.state.i.id != user.id && user.isFollowed">{{ $t('followsYou') }}</span> + <div class="actions" v-if="$store.getters.isSignedIn"> + <button @click="menu" class="menu _button" ref="menu"><fa :icon="faEllipsisH"/></button> + <x-follow-button v-if="$store.state.i.id != user.id" :user="user" :inline="true" :transparent="false" class="koudoku"/> + </div> + </div> + <mk-avatar class="avatar" :user="user" :disable-preview="true"/> + <div class="title"> + <mk-user-name :user="user" :nowrap="false" class="name"/> + <div class="bottom"> + <span class="username"><mk-acct :user="user" :detail="true" /></span> + <span v-if="user.isAdmin" :title="$t('isAdmin')"><fa :icon="faBookmark"/></span> + <span v-if="user.isLocked" :title="$t('isLocked')"><fa :icon="faLock"/></span> + <span v-if="user.isBot" :title="$t('isBot')"><fa :icon="faRobot"/></span> + </div> + </div> + <div class="description"> + <mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> + <p v-else class="empty">{{ $t('noAccountDescription') }}</p> + </div> + <div class="fields system"> + <dl class="field" v-if="user.location"> + <dt class="name"><fa :icon="faMapMarker" fixed-width/> {{ $t('location') }}</dt> + <dd class="value">{{ user.location }}</dd> + </dl> + <dl class="field" v-if="user.birthday"> + <dt class="name"><fa :icon="faBirthdayCake" fixed-width/> {{ $t('birthday') }}</dt> + <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd> + </dl> + <dl class="field"> + <dt class="name"><fa :icon="faCalendarAlt" fixed-width/> {{ $t('registeredDate') }}</dt> + <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<mk-time :time="user.createdAt"/>)</dd> + </dl> + </div> + <div class="fields" v-if="user.fields.length > 0"> + <dl class="field" v-for="(field, i) in user.fields" :key="i"> + <dt class="name"> + <mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/> + </dt> + <dd class="value"> + <mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :colored="false"/> + </dd> + </dl> + </div> + <div class="status" v-if="user.host === null"> + <router-link :to="user | userPage()" :class="{ active: $route.name === 'user' }"> + <b>{{ user.notesCount | number }}</b> + <span>{{ $t('notes') }}</span> + </router-link> + <router-link :to="user | userPage('following')" :class="{ active: $route.name === 'userFollowing' }"> + <b>{{ user.followingCount | number }}</b> + <span>{{ $t('following') }}</span> + </router-link> + <router-link :to="user | userPage('followers')" :class="{ active: $route.name === 'userFollowers' }"> + <b>{{ user.followersCount | number }}</b> + <span>{{ $t('followers') }}</span> + </router-link> + </div> + </div> + </transition> + <router-view :user="user"></router-view> + <template v-if="$route.name == 'user'"> + <sequential-entrance class="pins"> + <x-note v-for="(note, i) in user.pinnedNotes" class="note" :note="note" :key="note.id" :data-index="i" :detail="true" :pinned="true"/> + </sequential-entrance> + <mk-container :body-togglable="true" class="content"> + <template #header><fa :icon="faImage"/>{{ $t('images') }}</template> + <div> + <x-photos :user="user" :key="user.id"/> + </div> + </mk-container> + <mk-container :body-togglable="true" class="content"> + <template #header><fa :icon="faChartBar"/>{{ $t('activity') }}</template> + <div style="padding:8px;"> + <x-activity :user="user" :key="user.id"/> + </div> + </mk-container> + <x-user-timeline :user="user"/> + </template> +</div> +<div v-else-if="error"> + <mk-error @retry="fetch()"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faEllipsisH, faRobot, faLock, faBookmark, faExclamationTriangle, faChartBar, faImage, faBirthdayCake, faMapMarker } from '@fortawesome/free-solid-svg-icons'; +import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons'; +import * as age from 's-age'; +import XUserTimeline from './index.timeline.vue'; +import XUserMenu from '../../components/user-menu.vue'; +import XNote from '../../components/note.vue'; +import XFollowButton from '../../components/follow-button.vue'; +import MkContainer from '../../components/ui/container.vue'; +import Progress from '../../scripts/loading'; +import parseAcct from '../../../misc/acct/parse'; + +export default Vue.extend({ + components: { + XUserTimeline, + XNote, + XFollowButton, + MkContainer, + XPhotos: () => import('./index.photos.vue').then(m => m.default), + XActivity: () => import('./index.activity.vue').then(m => m.default), + }, + + metaInfo() { + return { + title: (this.user ? '@' + Vue.filter('acct')(this.user).replace('@', ' | ') : null) as string + }; + }, + + data() { + return { + user: null, + error: null, + parallaxAnimationId: null, + faEllipsisH, faRobot, faLock, faBookmark, faExclamationTriangle, faChartBar, faImage, faBirthdayCake, faMapMarker, faCalendarAlt + }; + }, + + computed: { + style(): any { + if (this.user.bannerUrl == null) return {}; + return { + backgroundImage: `url(${ this.user.bannerUrl })` + }; + }, + + age(): number { + return age(this.user.birthday); + } + }, + + watch: { + $route: 'fetch' + }, + + created() { + this.fetch(); + }, + + mounted() { + window.requestAnimationFrame(this.parallaxLoop); + window.addEventListener('scroll', this.parallax, { passive: true }); + document.addEventListener('touchmove', this.parallax, { passive: true }); + this.$once('hook:beforeDestroy', () => { + window.cancelAnimationFrame(this.parallaxAnimationId); + window.removeEventListener('scroll', this.parallax); + document.removeEventListener('touchmove', this.parallax); + }); + }, + + methods: { + fetch() { + Progress.start(); + this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => { + this.user = user; + }).catch(e => { + this.error = e; + }).finally(() => { + Progress.done(); + }); + }, + + menu() { + this.$root.new(XUserMenu, { + source: this.$refs.menu, + user: this.user + }); + }, + + parallaxLoop() { + this.parallaxAnimationId = window.requestAnimationFrame(this.parallaxLoop); + this.parallax(); + }, + + parallax() { + const banner = this.$refs.banner as any; + if (banner == null) return; + + const top = window.scrollY; + + if (top < 0) return; + + const z = 1.75; // 奥行き(小さいほど奥) + const pos = -(top / z); + banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-user-page { + > .remote-caution { + font-size: 0.8em; + padding: 16px; + margin-bottom: var(--margin); + + > a { + margin-left: 4px; + color: var(--accent); + } + } + + > .profile { + position: relative; + margin-bottom: var(--margin); + overflow: hidden; + + > .banner-container { + position: relative; + height: 250px; + overflow: hidden; + background-size: cover; + background-position: center; + + @media (max-width: 500px) { + height: 140px; + } + + > .banner { + height: 100%; + background-color: #4c5e6d; + background-size: cover; + background-position: center; + box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; + } + + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 78px; + background: linear-gradient(transparent, rgba(#000, 0.7)); + + @media (max-width: 500px) { + display: none; + } + } + + > .followed { + position: absolute; + top: 12px; + left: 12px; + padding: 4px 6px; + color: #fff; + background: rgba(0, 0, 0, 0.7); + font-size: 12px; + } + + > .actions { + position: absolute; + top: 12px; + right: 12px; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + background: rgba(0, 0, 0, 0.2); + padding: 8px; + border-radius: 24px; + + > .menu { + vertical-align: bottom; + height: 31px; + width: 31px; + color: #fff; + text-shadow: 0 0 8px #000; + font-size: 16px; + } + + > .koudoku { + margin-left: 4px; + vertical-align: bottom; + } + } + + > .title { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 0 0 8px 154px; + box-sizing: border-box; + color: #fff; + + @media (max-width: 500px) { + display: none; + } + + > .name { + display: block; + margin: 0; + line-height: 32px; + font-weight: bold; + font-size: 1.8em; + text-shadow: 0 0 8px #000; + } + + > .bottom { + > * { + display: inline-block; + margin-right: 16px; + line-height: 20px; + opacity: 0.8; + + &.username { + font-weight: bold; + } + } + } + } + } + + > .title { + display: none; + text-align: center; + padding: 50px 8px 16px 8px; + font-weight: bold; + border-bottom: solid 1px var(--divider); + + @media (max-width: 500px) { + display: block; + } + + > .bottom { + > * { + display: inline-block; + margin-right: 8px; + opacity: 0.8; + } + } + } + + > .avatar { + display: block; + position: absolute; + top: 170px; + left: 16px; + z-index: 2; + width: 120px; + height: 120px; + box-shadow: 1px 1px 3px rgba(#000, 0.2); + + @media (max-width: 500px) { + top: 90px; + left: 0; + right: 0; + width: 92px; + height: 92px; + margin: auto; + } + } + + > .description { + padding: 24px 24px 24px 154px; + font-size: 15px; + + @media (max-width: 500px) { + padding: 16px; + text-align: center; + } + + > .empty { + margin: 0; + opacity: 0.5; + } + } + + > .fields { + padding: 24px; + font-size: 14px; + border-top: solid 1px var(--divider); + + @media (max-width: 500px) { + padding: 16px; + } + + > .field { + display: flex; + padding: 0; + margin: 0; + align-items: center; + + &:not(:last-child) { + margin-bottom: 8px; + } + + > .name { + width: 30%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: bold; + text-align: center; + } + + > .value { + width: 70%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + &.system > .field > .name { + } + } + + > .status { + display: flex; + padding: 24px; + border-top: solid 1px var(--divider); + + @media (max-width: 500px) { + padding: 16px; + } + + > a { + flex: 1; + text-align: center; + + &.active { + color: var(--accent); + } + + &:hover { + text-decoration: none; + } + + > b { + display: block; + line-height: 16px; + } + + > span { + font-size: 70%; + } + } + } + } + + > .pins { + > .note { + margin-bottom: var(--margin); + } + } + + > .content { + margin-bottom: var(--margin); + } +} +</style> diff --git a/src/client/router.ts b/src/client/router.ts new file mode 100644 index 0000000000..7eb12c8e44 --- /dev/null +++ b/src/client/router.ts @@ -0,0 +1,53 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import MkIndex from './pages/index.vue'; + +Vue.use(VueRouter); + +export const router = new VueRouter({ + mode: 'history', + routes: [ + { path: '/', name: 'index', component: MkIndex }, + { path: '/@:user', name: 'user', component: () => import('./pages/user/index.vue').then(m => m.default), children: [ + { path: 'following', name: 'userFollowing', component: () => import('./pages/user/follow-list.vue').then(m => m.default), props: { type: 'following' } }, + { path: 'followers', name: 'userFollowers', component: () => import('./pages/user/follow-list.vue').then(m => m.default), props: { type: 'followers' } }, + ]}, + { path: '/@:user/pages/:page', component: () => import('./pages/page.vue').then(m => m.default), props: route => ({ pageName: route.params.page, username: route.params.user }) }, + { path: '/@:user/pages/:pageName/view-source', component: () => import('./pages/page-editor/page-editor.vue').then(m => m.default), props: route => ({ initUser: route.params.user, initPageName: route.params.pageName }) }, + { path: '/announcements', component: () => import('./pages/announcements.vue').then(m => m.default) }, + { path: '/about', component: () => import('./pages/about.vue').then(m => m.default) }, + { path: '/featured', component: () => import('./pages/featured.vue').then(m => m.default) }, + { path: '/explore', component: () => import('./pages/explore.vue').then(m => m.default) }, + { path: '/explore/tags/:tag', props: true, component: () => import('./pages/explore.vue').then(m => m.default) }, + { path: '/search', component: () => import('./pages/search.vue').then(m => m.default) }, + { path: '/my/favorites', component: () => import('./pages/favorites.vue').then(m => m.default) }, + { path: '/my/messages', component: () => import('./pages/messages.vue').then(m => m.default) }, + { path: '/my/mentions', component: () => import('./pages/mentions.vue').then(m => m.default) }, + { path: '/my/messaging', name: 'messaging', component: () => import('./pages/messaging.vue').then(m => m.default) }, + { path: '/my/messaging/:user', component: () => import('./pages/messaging-room.vue').then(m => m.default) }, + { path: '/my/drive', name: 'drive', component: () => import('./pages/drive.vue').then(m => m.default) }, + { path: '/my/drive/folder/:folder', component: () => import('./pages/drive.vue').then(m => m.default) }, + { path: '/my/pages', name: 'pages', component: () => import('./pages/pages.vue').then(m => m.default) }, + { path: '/my/pages/new', component: () => import('./pages/page-editor/page-editor.vue').then(m => m.default) }, + { path: '/my/pages/edit/:pageId', component: () => import('./pages/page-editor/page-editor.vue').then(m => m.default), props: route => ({ initPageId: route.params.pageId }) }, + { path: '/my/settings', component: () => import('./pages/settings/index.vue').then(m => m.default) }, + { path: '/my/follow-requests', component: () => import('./pages/follow-requests.vue').then(m => m.default) }, + { path: '/my/lists', component: () => import('./pages/my-lists/index.vue').then(m => m.default) }, + { path: '/my/lists/:list', component: () => import('./pages/my-lists/list.vue').then(m => m.default) }, + { path: '/my/antennas', component: () => import('./pages/my-antennas/index.vue').then(m => m.default) }, + { path: '/instance', component: () => import('./pages/instance/index.vue').then(m => m.default) }, + { path: '/instance/emojis', component: () => import('./pages/instance/emojis.vue').then(m => m.default) }, + { path: '/instance/users', component: () => import('./pages/instance/users.vue').then(m => m.default) }, + { path: '/instance/files', component: () => import('./pages/instance/files.vue').then(m => m.default) }, + { path: '/instance/monitor', component: () => import('./pages/instance/monitor.vue').then(m => m.default) }, + { path: '/instance/queue', component: () => import('./pages/instance/queue.vue').then(m => m.default) }, + { path: '/instance/stats', component: () => import('./pages/instance/stats.vue').then(m => m.default) }, + { path: '/instance/federation', component: () => import('./pages/instance/federation.vue').then(m => m.default) }, + { path: '/instance/announcements', component: () => import('./pages/instance/announcements.vue').then(m => m.default) }, + { path: '/notes/:note', name: 'note', component: () => import('./pages/note.vue').then(m => m.default) }, + { path: '/tags/:tag', component: () => import('./pages/tag.vue').then(m => m.default) }, + { path: '/auth/:token', component: () => import('./pages/auth.vue').then(m => m.default) }, + { path: '/authorize-follow', component: () => import('./pages/follow.vue').then(m => m.default) }, + /*{ path: '*', component: MkNotFound }*/ + ] +}); diff --git a/src/client/app/common/scripts/2fa.ts b/src/client/scripts/2fa.ts index f638cce156..e431361aac 100644 --- a/src/client/app/common/scripts/2fa.ts +++ b/src/client/scripts/2fa.ts @@ -1,5 +1,5 @@ export function hexifyAB(buffer) { return Array.from(new Uint8Array(buffer)) - .map(item => item.toString(16).padStart(2, 0)) + .map(item => item.toString(16).padStart(2, '0')) .join(''); } diff --git a/src/client/scripts/aiscript/evaluator.ts b/src/client/scripts/aiscript/evaluator.ts new file mode 100644 index 0000000000..cc1adf4499 --- /dev/null +++ b/src/client/scripts/aiscript/evaluator.ts @@ -0,0 +1,267 @@ +import autobind from 'autobind-decorator'; +import * as seedrandom from 'seedrandom'; +import { Variable, PageVar, envVarsDef, funcDefs, Block, isFnBlock } from '.'; +import { version } from '../../config'; + +type Fn = { + slots: string[]; + exec: (args: Record<string, any>) => ReturnType<ASEvaluator['evaluate']>; +}; + +/** + * AiScript evaluator + */ +export class ASEvaluator { + private variables: Variable[]; + private pageVars: PageVar[]; + private envVars: Record<keyof typeof envVarsDef, any>; + + private opts: { + randomSeed: string; user?: any; visitor?: any; page?: any; url?: string; + }; + + constructor(variables: Variable[], pageVars: PageVar[], opts: ASEvaluator['opts']) { + this.variables = variables; + this.pageVars = pageVars; + this.opts = opts; + + const date = new Date(); + + this.envVars = { + AI: 'kawaii', + VERSION: version, + URL: opts.page ? `${opts.url}/@${opts.page.user.username}/pages/${opts.page.name}` : '', + LOGIN: opts.visitor != null, + NAME: opts.visitor ? opts.visitor.name || opts.visitor.username : '', + USERNAME: opts.visitor ? opts.visitor.username : '', + USERID: opts.visitor ? opts.visitor.id : '', + NOTES_COUNT: opts.visitor ? opts.visitor.notesCount : 0, + FOLLOWERS_COUNT: opts.visitor ? opts.visitor.followersCount : 0, + FOLLOWING_COUNT: opts.visitor ? opts.visitor.followingCount : 0, + IS_CAT: opts.visitor ? opts.visitor.isCat : false, + MY_NOTES_COUNT: opts.user ? opts.user.notesCount : 0, + MY_FOLLOWERS_COUNT: opts.user ? opts.user.followersCount : 0, + MY_FOLLOWING_COUNT: opts.user ? opts.user.followingCount : 0, + SEED: opts.randomSeed ? opts.randomSeed : '', + YMD: `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`, + NULL: null + }; + } + + @autobind + public updatePageVar(name: string, value: any) { + const pageVar = this.pageVars.find(v => v.name === name); + if (pageVar !== undefined) { + pageVar.value = value; + } else { + throw new AiScriptError(`No such page var '${name}'`); + } + } + + @autobind + public updateRandomSeed(seed: string) { + this.opts.randomSeed = seed; + this.envVars.SEED = seed; + } + + @autobind + private interpolate(str: string, scope: Scope) { + return str.replace(/{(.+?)}/g, match => { + const v = scope.getState(match.slice(1, -1).trim()); + return v == null ? 'NULL' : v.toString(); + }); + } + + @autobind + public evaluateVars(): Record<string, any> { + const values: Record<string, any> = {}; + + for (const [k, v] of Object.entries(this.envVars)) { + values[k] = v; + } + + for (const v of this.pageVars) { + values[v.name] = v.value; + } + + for (const v of this.variables) { + values[v.name] = this.evaluate(v, new Scope([values])); + } + + return values; + } + + @autobind + private evaluate(block: Block, scope: Scope): any { + if (block.type === null) { + return null; + } + + if (block.type === 'number') { + return parseInt(block.value, 10); + } + + if (block.type === 'text' || block.type === 'multiLineText') { + return this.interpolate(block.value || '', scope); + } + + if (block.type === 'textList') { + return this.interpolate(block.value || '', scope).trim().split('\n'); + } + + if (block.type === 'ref') { + return scope.getState(block.value); + } + + if (isFnBlock(block)) { // ユーザー関数定義 + return { + slots: block.value.slots.map(x => x.name), + exec: (slotArg: Record<string, any>) => { + return this.evaluate(block.value.expression, scope.createChildScope(slotArg, block.id)); + } + } as Fn; + } + + if (block.type.startsWith('fn:')) { // ユーザー関数呼び出し + const fnName = block.type.split(':')[1]; + const fn = scope.getState(fnName); + const args = {} as Record<string, any>; + for (let i = 0; i < fn.slots.length; i++) { + const name = fn.slots[i]; + args[name] = this.evaluate(block.args[i], scope); + } + return fn.exec(args); + } + + if (block.args === undefined) return null; + + const date = new Date(); + const day = `${this.opts.visitor ? this.opts.visitor.id : ''} ${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; + + const funcs: { [p in keyof typeof funcDefs]: Function } = { + not: (a: boolean) => !a, + or: (a: boolean, b: boolean) => a || b, + and: (a: boolean, b: boolean) => a && b, + eq: (a: any, b: any) => a === b, + notEq: (a: any, b: any) => a !== b, + gt: (a: number, b: number) => a > b, + lt: (a: number, b: number) => a < b, + gtEq: (a: number, b: number) => a >= b, + ltEq: (a: number, b: number) => a <= b, + if: (bool: boolean, a: any, b: any) => bool ? a : b, + for: (times: number, fn: Fn) => { + const result = []; + for (let i = 0; i < times; i++) { + result.push(fn.exec({ + [fn.slots[0]]: i + 1 + })); + } + return result; + }, + add: (a: number, b: number) => a + b, + subtract: (a: number, b: number) => a - b, + multiply: (a: number, b: number) => a * b, + divide: (a: number, b: number) => a / b, + mod: (a: number, b: number) => a % b, + round: (a: number) => Math.round(a), + strLen: (a: string) => a.length, + strPick: (a: string, b: number) => a[b - 1], + strReplace: (a: string, b: string, c: string) => a.split(b).join(c), + strReverse: (a: string) => a.split('').reverse().join(''), + join: (texts: string[], separator: string) => texts.join(separator || ''), + stringToNumber: (a: string) => parseInt(a), + numberToString: (a: number) => a.toString(), + splitStrByLine: (a: string) => a.split('\n'), + pick: (list: any[], i: number) => list[i - 1], + listLen: (list: any[]) => list.length, + random: (probability: number) => Math.floor(seedrandom(`${this.opts.randomSeed}:${block.id}`)() * 100) < probability, + rannum: (min: number, max: number) => min + Math.floor(seedrandom(`${this.opts.randomSeed}:${block.id}`)() * (max - min + 1)), + randomPick: (list: any[]) => list[Math.floor(seedrandom(`${this.opts.randomSeed}:${block.id}`)() * list.length)], + dailyRandom: (probability: number) => Math.floor(seedrandom(`${day}:${block.id}`)() * 100) < probability, + dailyRannum: (min: number, max: number) => min + Math.floor(seedrandom(`${day}:${block.id}`)() * (max - min + 1)), + dailyRandomPick: (list: any[]) => list[Math.floor(seedrandom(`${day}:${block.id}`)() * list.length)], + seedRandom: (seed: any, probability: number) => Math.floor(seedrandom(seed)() * 100) < probability, + seedRannum: (seed: any, min: number, max: number) => min + Math.floor(seedrandom(seed)() * (max - min + 1)), + seedRandomPick: (seed: any, list: any[]) => list[Math.floor(seedrandom(seed)() * list.length)], + DRPWPM: (list: string[]) => { + const xs = []; + let totalFactor = 0; + for (const x of list) { + const parts = x.split(' '); + const factor = parseInt(parts.pop()!, 10); + const text = parts.join(' '); + totalFactor += factor; + xs.push({ factor, text }); + } + const r = seedrandom(`${day}:${block.id}`)() * totalFactor; + let stackedFactor = 0; + for (const x of xs) { + if (r >= stackedFactor && r <= stackedFactor + x.factor) { + return x.text; + } else { + stackedFactor += x.factor; + } + } + return xs[0].text; + }, + }; + + const fnName = block.type; + const fn = (funcs as any)[fnName]; + if (fn == null) { + throw new AiScriptError(`No such function '${fnName}'`); + } else { + return fn(...block.args.map(x => this.evaluate(x, scope))); + } + } +} + +class AiScriptError extends Error { + public info?: any; + + constructor(message: string, info?: any) { + super(message); + + this.info = info; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, AiScriptError); + } + } +} + +class Scope { + private layerdStates: Record<string, any>[]; + public name: string; + + constructor(layerdStates: Scope['layerdStates'], name?: Scope['name']) { + this.layerdStates = layerdStates; + this.name = name || 'anonymous'; + } + + @autobind + public createChildScope(states: Record<string, any>, name?: Scope['name']): Scope { + const layer = [states, ...this.layerdStates]; + return new Scope(layer, name); + } + + /** + * 指定した名前の変数の値を取得します + * @param name 変数名 + */ + @autobind + public getState(name: string): any { + for (const later of this.layerdStates) { + const state = later[name]; + if (state !== undefined) { + return state; + } + } + + throw new AiScriptError( + `No such variable '${name}' in scope '${this.name}'`, { + scope: this.layerdStates + }); + } +} diff --git a/src/client/scripts/aiscript/index.ts b/src/client/scripts/aiscript/index.ts new file mode 100644 index 0000000000..f2de1bb40d --- /dev/null +++ b/src/client/scripts/aiscript/index.ts @@ -0,0 +1,140 @@ +/** + * AiScript + */ + +import { + faMagic, + faSquareRootAlt, + faAlignLeft, + faShareAlt, + faPlus, + faMinus, + faTimes, + faDivide, + faList, + faQuoteRight, + faEquals, + faGreaterThan, + faLessThan, + faGreaterThanEqual, + faLessThanEqual, + faNotEqual, + faDice, + faSortNumericUp, + faExchangeAlt, + faRecycle, + faIndent, + faCalculator, +} from '@fortawesome/free-solid-svg-icons'; +import { faFlag } from '@fortawesome/free-regular-svg-icons'; + +export type Block<V = any> = { + id: string; + type: string; + args: Block[]; + value: V; +}; + +export type FnBlock = Block<{ + slots: { + name: string; + type: Type; + }[]; + expression: Block; +}>; + +export type Variable = Block & { + name: string; +}; + +export type Type = 'string' | 'number' | 'boolean' | 'stringArray' | null; + +export const funcDefs: Record<string, { in: any[]; out: any; category: string; icon: any; }> = { + if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: faShareAlt, }, + for: { in: ['number', 'function'], out: null, category: 'flow', icon: faRecycle, }, + not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: faFlag, }, + or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: faFlag, }, + and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: faFlag, }, + add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faPlus, }, + subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faMinus, }, + multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faTimes, }, + divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faDivide, }, + mod: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faDivide, }, + round: { in: ['number'], out: 'number', category: 'operation', icon: faCalculator, }, + eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: faEquals, }, + notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: faNotEqual, }, + gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faGreaterThan, }, + lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faLessThan, }, + gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faGreaterThanEqual, }, + ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faLessThanEqual, }, + strLen: { in: ['string'], out: 'number', category: 'text', icon: faQuoteRight, }, + strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: faQuoteRight, }, + strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: faQuoteRight, }, + strReverse: { in: ['string'], out: 'string', category: 'text', icon: faQuoteRight, }, + join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: faQuoteRight, }, + stringToNumber: { in: ['string'], out: 'number', category: 'convert', icon: faExchangeAlt, }, + numberToString: { in: ['number'], out: 'string', category: 'convert', icon: faExchangeAlt, }, + splitStrByLine: { in: ['string'], out: 'stringArray', category: 'convert', icon: faExchangeAlt, }, + pick: { in: [null, 'number'], out: null, category: 'list', icon: faIndent, }, + listLen: { in: [null], out: 'number', category: 'list', icon: faIndent, }, + rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: faDice, }, + dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: faDice, }, + seedRannum: { in: [null, 'number', 'number'], out: 'number', category: 'random', icon: faDice, }, + random: { in: ['number'], out: 'boolean', category: 'random', icon: faDice, }, + dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: faDice, }, + seedRandom: { in: [null, 'number'], out: 'boolean', category: 'random', icon: faDice, }, + randomPick: { in: [0], out: 0, category: 'random', icon: faDice, }, + dailyRandomPick: { in: [0], out: 0, category: 'random', icon: faDice, }, + seedRandomPick: { in: [null, 0], out: 0, category: 'random', icon: faDice, }, + DRPWPM: { in: ['stringArray'], out: 'string', category: 'random', icon: faDice, }, // dailyRandomPickWithProbabilityMapping +}; + +export const literalDefs: Record<string, { out: any; category: string; icon: any; }> = { + text: { out: 'string', category: 'value', icon: faQuoteRight, }, + multiLineText: { out: 'string', category: 'value', icon: faAlignLeft, }, + textList: { out: 'stringArray', category: 'value', icon: faList, }, + number: { out: 'number', category: 'value', icon: faSortNumericUp, }, + ref: { out: null, category: 'value', icon: faMagic, }, + fn: { out: 'function', category: 'value', icon: faSquareRootAlt, }, +}; + +export const blockDefs = [ + ...Object.entries(literalDefs).map(([k, v]) => ({ + type: k, out: v.out, category: v.category, icon: v.icon + })), + ...Object.entries(funcDefs).map(([k, v]) => ({ + type: k, out: v.out, category: v.category, icon: v.icon + })) +]; + +export function isFnBlock(block: Block): block is FnBlock { + return block.type === 'fn'; +} + +export type PageVar = { name: string; value: any; type: Type; }; + +export const envVarsDef: Record<string, Type> = { + AI: 'string', + URL: 'string', + VERSION: 'string', + LOGIN: 'boolean', + NAME: 'string', + USERNAME: 'string', + USERID: 'string', + NOTES_COUNT: 'number', + FOLLOWERS_COUNT: 'number', + FOLLOWING_COUNT: 'number', + IS_CAT: 'boolean', + MY_NOTES_COUNT: 'number', + MY_FOLLOWERS_COUNT: 'number', + MY_FOLLOWING_COUNT: 'number', + SEED: null, + YMD: 'string', + NULL: null, +}; + +export function isLiteralBlock(v: Block) { + if (v.type === null) return true; + if (literalDefs[v.type]) return true; + return false; +} diff --git a/src/client/scripts/aiscript/type-checker.ts b/src/client/scripts/aiscript/type-checker.ts new file mode 100644 index 0000000000..817e549864 --- /dev/null +++ b/src/client/scripts/aiscript/type-checker.ts @@ -0,0 +1,186 @@ +import autobind from 'autobind-decorator'; +import { Type, Block, funcDefs, envVarsDef, Variable, PageVar, isLiteralBlock } from '.'; + +type TypeError = { + arg: number; + expect: Type; + actual: Type; +}; + +/** + * AiScript type checker + */ +export class ASTypeChecker { + public variables: Variable[]; + public pageVars: PageVar[]; + + constructor(variables: ASTypeChecker['variables'] = [], pageVars: ASTypeChecker['pageVars'] = []) { + this.variables = variables; + this.pageVars = pageVars; + } + + @autobind + public typeCheck(v: Block): TypeError | null { + if (isLiteralBlock(v)) return null; + + const def = funcDefs[v.type]; + if (def == null) { + throw new Error('Unknown type: ' + v.type); + } + + const generic: Type[] = []; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + const type = this.infer(v.args[i]); + if (type === null) continue; + + if (typeof arg === 'number') { + if (generic[arg] === undefined) { + generic[arg] = type; + } else if (type !== generic[arg]) { + return { + arg: i, + expect: generic[arg], + actual: type + }; + } + } else if (type !== arg) { + return { + arg: i, + expect: arg, + actual: type + }; + } + } + + return null; + } + + @autobind + public getExpectedType(v: Block, slot: number): Type { + const def = funcDefs[v.type]; + if (def == null) { + throw new Error('Unknown type: ' + v.type); + } + + const generic: Type[] = []; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + const type = this.infer(v.args[i]); + if (type === null) continue; + + if (typeof arg === 'number') { + if (generic[arg] === undefined) { + generic[arg] = type; + } + } + } + + if (typeof def.in[slot] === 'number') { + return generic[def.in[slot]] || null; + } else { + return def.in[slot]; + } + } + + @autobind + public infer(v: Block): Type { + if (v.type === null) return null; + if (v.type === 'text') return 'string'; + if (v.type === 'multiLineText') return 'string'; + if (v.type === 'textList') return 'stringArray'; + if (v.type === 'number') return 'number'; + if (v.type === 'ref') { + const variable = this.variables.find(va => va.name === v.value); + if (variable) { + return this.infer(variable); + } + + const pageVar = this.pageVars.find(va => va.name === v.value); + if (pageVar) { + return pageVar.type; + } + + const envVar = envVarsDef[v.value]; + if (envVar !== undefined) { + return envVar; + } + + return null; + } + if (v.type === 'fn') return null; // todo + if (v.type.startsWith('fn:')) return null; // todo + + const generic: Type[] = []; + + const def = funcDefs[v.type]; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + if (typeof arg === 'number') { + const type = this.infer(v.args[i]); + + if (generic[arg] === undefined) { + generic[arg] = type; + } else { + if (type !== generic[arg]) { + generic[arg] = null; + } + } + } + } + + if (typeof def.out === 'number') { + return generic[def.out]; + } else { + return def.out; + } + } + + @autobind + public getVarByName(name: string): Variable { + const v = this.variables.find(x => x.name === name); + if (v !== undefined) { + return v; + } else { + throw new Error(`No such variable '${name}'`); + } + } + + @autobind + public getVarsByType(type: Type): Variable[] { + if (type == null) return this.variables; + return this.variables.filter(x => (this.infer(x) === null) || (this.infer(x) === type)); + } + + @autobind + public getEnvVarsByType(type: Type): string[] { + if (type == null) return Object.keys(envVarsDef); + return Object.entries(envVarsDef).filter(([k, v]) => v === null || type === v).map(([k, v]) => k); + } + + @autobind + public getPageVarsByType(type: Type): string[] { + if (type == null) return this.pageVars.map(v => v.name); + return this.pageVars.filter(v => type === v.type).map(v => v.name); + } + + @autobind + public isUsedName(name: string) { + if (this.variables.some(v => v.name === name)) { + return true; + } + + if (this.pageVars.some(v => v.name === name)) { + return true; + } + + if (envVarsDef[name]) { + return true; + } + + return false; + } +} diff --git a/src/client/app/common/scripts/collect-page-vars.ts b/src/client/scripts/collect-page-vars.ts index a4096fb2c2..a4096fb2c2 100644 --- a/src/client/app/common/scripts/collect-page-vars.ts +++ b/src/client/scripts/collect-page-vars.ts diff --git a/src/client/app/common/scripts/compose-notification.ts b/src/client/scripts/compose-notification.ts index ec854f2f4d..bf32552506 100644 --- a/src/client/app/common/scripts/compose-notification.ts +++ b/src/client/scripts/compose-notification.ts @@ -1,6 +1,5 @@ -import getNoteSummary from '../../../../misc/get-note-summary'; -import getReactionEmoji from '../../../../misc/get-reaction-emoji'; -import getUserName from '../../../../misc/get-user-name'; +import getNoteSummary from '../../misc/get-note-summary'; +import getUserName from '../../misc/get-user-name'; type Notification = { title: string; @@ -20,20 +19,6 @@ export default function(type, data): Notification { icon: data.url }; - case 'unreadMessagingMessage': - return { - title: `New message from ${getUserName(data.user)}`, - body: data.text, // TODO: getMessagingMessageSummary(data), - icon: data.user.avatarUrl - }; - - case 'reversiInvited': - return { - title: 'Play reversi with me', - body: `You got reversi invitation from ${getUserName(data.parent)}`, - icon: data.parent.avatarUrl - }; - case 'notification': switch (data.type) { case 'mention': @@ -59,7 +44,7 @@ export default function(type, data): Notification { case 'reaction': return { - title: `${getUserName(data.user)}: ${getReactionEmoji(data.reaction)}:`, + title: `${getUserName(data.user)}: ${data.reaction}:`, body: getNoteSummary(data.note), icon: data.user.avatarUrl }; diff --git a/src/client/app/common/scripts/contains.ts b/src/client/scripts/contains.ts index a5071b3f25..770bda63bb 100644 --- a/src/client/app/common/scripts/contains.ts +++ b/src/client/scripts/contains.ts @@ -1,4 +1,5 @@ -export default (parent, child) => { +export default (parent, child, checkSame = true) => { + if (checkSame && parent === child) return true; let node = child.parentNode; while (node) { if (node == parent) return true; diff --git a/src/client/app/common/scripts/copy-to-clipboard.ts b/src/client/scripts/copy-to-clipboard.ts index ab13cab970..ab13cab970 100644 --- a/src/client/app/common/scripts/copy-to-clipboard.ts +++ b/src/client/scripts/copy-to-clipboard.ts diff --git a/src/client/app/common/scripts/gen-search-query.ts b/src/client/scripts/gen-search-query.ts index fc26cb7f78..2520da75df 100644 --- a/src/client/app/common/scripts/gen-search-query.ts +++ b/src/client/scripts/gen-search-query.ts @@ -1,5 +1,5 @@ -import parseAcct from '../../../../misc/acct/parse'; -import { host as localHost } from '../../config'; +import parseAcct from '../../misc/acct/parse'; +import { host as localHost } from '../config'; export async function genSearchQuery(v: any, q: string) { let host: string; diff --git a/src/client/scripts/get-instance-name.ts b/src/client/scripts/get-instance-name.ts new file mode 100644 index 0000000000..b12a3a4c67 --- /dev/null +++ b/src/client/scripts/get-instance-name.ts @@ -0,0 +1,8 @@ +export function getInstanceName() { + const siteName = document.querySelector('meta[property="og:site_name"]') as HTMLMetaElement; + if (siteName && siteName.content) { + return siteName.content; + } + + return 'Misskey'; +} diff --git a/src/client/app/common/scripts/get-md5.ts b/src/client/scripts/get-md5.ts index b002d762b1..b002d762b1 100644 --- a/src/client/app/common/scripts/get-md5.ts +++ b/src/client/scripts/get-md5.ts diff --git a/src/client/app/common/scripts/get-static-image-url.ts b/src/client/scripts/get-static-image-url.ts index 7460ca38f2..eff76af256 100644 --- a/src/client/app/common/scripts/get-static-image-url.ts +++ b/src/client/scripts/get-static-image-url.ts @@ -1,5 +1,5 @@ -import { url as instanceUrl } from '../../config'; -import * as url from '../../../../prelude/url'; +import { url as instanceUrl } from '../config'; +import * as url from '../../prelude/url'; export function getStaticImageUrl(baseUrl: string): string { const u = new URL(baseUrl); diff --git a/src/client/app/common/hotkey.ts b/src/client/scripts/hotkey.ts index a53d3f479e..ec627ab15b 100644 --- a/src/client/app/common/hotkey.ts +++ b/src/client/scripts/hotkey.ts @@ -1,5 +1,5 @@ import keyCode from './keycode'; -import { concat } from '../../../prelude/array'; +import { concat } from '../../prelude/array'; type pattern = { which: string[]; diff --git a/src/client/app/common/keycode.ts b/src/client/scripts/keycode.ts index 5786c1dc0a..5786c1dc0a 100644 --- a/src/client/app/common/keycode.ts +++ b/src/client/scripts/keycode.ts diff --git a/src/client/app/common/scripts/loading.ts b/src/client/scripts/loading.ts index 70a3a4c85e..70a3a4c85e 100644 --- a/src/client/app/common/scripts/loading.ts +++ b/src/client/scripts/loading.ts diff --git a/src/client/app/common/scripts/paging.ts b/src/client/scripts/paging.ts index b4f2ec1ae1..b24d705f15 100644 --- a/src/client/app/common/scripts/paging.ts +++ b/src/client/scripts/paging.ts @@ -4,7 +4,6 @@ export default (opts) => ({ data() { return { items: [], - queue: [], offset: 0, fetching: true, moreFetching: false, @@ -20,14 +19,10 @@ export default (opts) => ({ error(): boolean { return !this.fetching && !this.inited; - } + }, }, watch: { - queue(x) { - if (opts.onQueueChanged) opts.onQueueChanged(this, x); - }, - pagination() { this.init(); } @@ -38,51 +33,31 @@ export default (opts) => ({ this.init(); }, - mounted() { - if (opts.captureWindowScroll) { - this.isScrollTop = () => { - return window.scrollY <= 8; - }; - - window.addEventListener('scroll', this.onScroll, { passive: true }); - } else if (opts.isContainer) { - this.isScrollTop = () => { - return this.$el.scrollTop <= 8; - }; - - this.$el.addEventListener('scroll', this.onScroll, { passive: true }); - } - }, - - beforeDestroy() { - if (opts.captureWindowScroll) { - window.removeEventListener('scroll', this.onScroll); - } else if (opts.isContainer) { - this.$el.removeEventListener('scroll', this.onScroll); - } - }, - methods: { + isScrollTop() { + return window.scrollY <= 8; + }, + updateItem(i, item) { Vue.set((this as any).items, i, item); }, reload() { - this.queue = []; this.items = []; this.init(); }, async init() { this.fetching = true; - if (opts.beforeInit) opts.beforeInit(this); + if (opts.before) opts.before(this); let params = typeof this.pagination.params === 'function' ? this.pagination.params(true) : this.pagination.params; if (params && params.then) params = await params; - await this.$root.api(this.pagination.endpoint, { - limit: (this.pagination.limit || 10) + 1, + const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint; + await this.$root.api(endpoint, { + limit: this.pagination.noPaging ? (this.pagination.limit || 10) : (this.pagination.limit || 10) + 1, ...params }).then(x => { - if (x.length == (this.pagination.limit || 10) + 1) { + if (!this.pagination.noPaging && (x.length === (this.pagination.limit || 10) + 1)) { x.pop(); this.items = x; this.more = true; @@ -93,10 +68,10 @@ export default (opts) => ({ this.offset = x.length; this.inited = true; this.fetching = false; - if (opts.onInited) opts.onInited(this); + if (opts.after) opts.after(this, null); }, e => { this.fetching = false; - if (opts.onInited) opts.onInited(this); + if (opts.after) opts.after(this, e); }); }, @@ -105,16 +80,17 @@ export default (opts) => ({ this.moreFetching = true; let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params; if (params && params.then) params = await params; - await this.$root.api(this.pagination.endpoint, { + const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint; + await this.$root.api(endpoint, { limit: (this.pagination.limit || 10) + 1, - ...(this.pagination.endpoint === 'notes/search' ? { + ...(this.pagination.offsetMode ? { offset: this.offset, } : { untilId: this.items[this.items.length - 1].id, }), ...params }).then(x => { - if (x.length == (this.pagination.limit || 10) + 1) { + if (x.length === (this.pagination.limit || 10) + 1) { x.pop(); this.items = this.items.concat(x); this.more = true; @@ -135,17 +111,15 @@ export default (opts) => ({ if (cancel) return; } - if (this.isScrollTop == null || this.isScrollTop()) { - // Prepend the item - this.items.unshift(item); + // Prepend the item + this.items.unshift(item); + if (this.isScrollTop()) { // オーバーフローしたら古い投稿は捨てる if (this.items.length >= opts.displayLimit) { this.items = this.items.slice(0, opts.displayLimit); this.more = true; } - } else { - this.queue.push(item); } }, @@ -153,37 +127,8 @@ export default (opts) => ({ this.items.push(item); }, - releaseQueue() { - for (const n of this.queue) { - this.prepend(n, true); - } - this.queue = []; - }, - - onScroll() { - if (this.isScrollTop()) { - this.onTop(); - } - - if (this.$store.state.settings.fetchOnScroll) { - // 親要素が display none だったら弾く - // https://github.com/syuilo/misskey/issues/1569 - // http://d.hatena.ne.jp/favril/20091105/1257403319 - if (this.$el.offsetHeight == 0) return; - - const bottomPosition = opts.isContainer ? this.$el.scrollHeight : document.body.offsetHeight; - - const currentBottomPosition = opts.isContainer ? this.$el.scrollTop + this.$el.clientHeight : window.scrollY + window.innerHeight; - if (currentBottomPosition > (bottomPosition - 8)) this.onBottom(); - } - }, - - onTop() { - this.releaseQueue(); + remove(find) { + this.items = this.items.filter(x => !find(x)); }, - - onBottom() { - this.fetchMore(); - } } }); diff --git a/src/client/app/common/scripts/please-login.ts b/src/client/scripts/please-login.ts index 7125541bb1..7125541bb1 100644 --- a/src/client/app/common/scripts/please-login.ts +++ b/src/client/scripts/please-login.ts diff --git a/src/client/app/common/scripts/search.ts b/src/client/scripts/search.ts index 2897ed6318..02dd39b035 100644 --- a/src/client/app/common/scripts/search.ts +++ b/src/client/scripts/search.ts @@ -28,7 +28,7 @@ export async function search(v: any, q: string) { v.$root.$emit('warp', date); v.$root.dialog({ icon: faHistory, - splash: true, + iconOnly: true, autoClose: true }); return; } @@ -36,7 +36,7 @@ export async function search(v: any, q: string) { if (q.startsWith('https://')) { const dialog = v.$root.dialog({ type: 'waiting', - text: v.$t('@.fetching-as-ap-object'), + text: v.$t('fetchingAsApObject') + '...', showOkButton: false, showCancelButton: false, cancelableByBgClick: false diff --git a/src/client/scripts/select-drive-file.ts b/src/client/scripts/select-drive-file.ts new file mode 100644 index 0000000000..004ddf2fd9 --- /dev/null +++ b/src/client/scripts/select-drive-file.ts @@ -0,0 +1,12 @@ +import DriveWindow from '../components/drive-window.vue'; + +export function selectDriveFile($root: any, multiple) { + return new Promise((res, rej) => { + const w = $root.new(DriveWindow, { + multiple + }); + w.$once('selected', files => { + res(multiple ? files : files[0]); + }); + }); +} diff --git a/src/client/app/common/scripts/stream.ts b/src/client/scripts/stream.ts index a1b4223b55..7f0e1280b6 100644 --- a/src/client/app/common/scripts/stream.ts +++ b/src/client/scripts/stream.ts @@ -1,8 +1,8 @@ import autobind from 'autobind-decorator'; import { EventEmitter } from 'eventemitter3'; import ReconnectingWebsocket from 'reconnecting-websocket'; -import { wsUrl } from '../../config'; -import MiOS from '../../mios'; +import { wsUrl } from '../config'; +import MiOS from '../mios'; /** * Misskey stream connection diff --git a/src/client/store.ts b/src/client/store.ts new file mode 100644 index 0000000000..c9f61b8112 --- /dev/null +++ b/src/client/store.ts @@ -0,0 +1,193 @@ +import Vuex from 'vuex'; +import createPersistedState from 'vuex-persistedstate'; +import * as nestedProperty from 'nested-property'; + +import MiOS from './mios'; + +const defaultSettings = { + keepCw: false, + showFullAcct: false, + rememberNoteVisibility: false, + defaultNoteVisibility: 'public', + uploadFolder: null, + pastedFileName: 'yyyy-MM-dd HH-mm-ss [{{number}}]', + wallpaper: null, + memo: null, + reactions: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], + widgets: [] +}; + +const defaultDeviceSettings = { + lang: null, + loadRawImages: false, + alwaysShowNsfw: false, + useOsDefaultEmojis: false, + accounts: [], + recentEmojis: [], + themes: [], + theme: 'light', +}; + +export default (os: MiOS) => new Vuex.Store({ + plugins: [createPersistedState({ + paths: ['i', 'device', 'settings'] + })], + + state: { + i: null, + }, + + getters: { + isSignedIn: state => state.i != null, + }, + + mutations: { + updateI(state, x) { + state.i = x; + }, + + updateIKeyValue(state, x) { + state.i[x.key] = x.value; + }, + }, + + actions: { + login(ctx, i) { + ctx.commit('updateI', i); + ctx.dispatch('settings/merge', i.clientData); + ctx.dispatch('addAcount', { id: i.id, i: localStorage.getItem('i') }); + }, + + addAcount(ctx, info) { + if (!ctx.state.device.accounts.some(x => x.id === info.id)) { + ctx.commit('device/set', { + key: 'accounts', + value: ctx.state.device.accounts.concat([{ id: info.id, token: info.i }]) + }); + } + }, + + logout(ctx) { + ctx.commit('updateI', null); + localStorage.removeItem('i'); + }, + + switchAccount(ctx, i) { + ctx.commit('updateI', i); + ctx.commit('settings/init', i.clientData); + localStorage.setItem('i', i.token); + }, + + mergeMe(ctx, me) { + for (const [key, value] of Object.entries(me)) { + ctx.commit('updateIKeyValue', { key, value }); + } + + if (me.clientData) { + ctx.dispatch('settings/merge', me.clientData); + } + }, + }, + + modules: { + device: { + namespaced: true, + + state: defaultDeviceSettings, + + mutations: { + set(state, x: { key: string; value: any }) { + state[x.key] = x.value; + }, + + setTl(state, x) { + state.tl = { + src: x.src, + arg: x.arg + }; + }, + + setVisibility(state, visibility) { + state.visibility = visibility; + }, + } + }, + + settings: { + namespaced: true, + + state: defaultSettings, + + mutations: { + set(state, x: { key: string; value: any }) { + nestedProperty.set(state, x.key, x.value); + }, + + init(state, x) { + for (const [key, value] of Object.entries(defaultSettings)) { + if (x[key]) { + state[key] = x[key]; + } else { + state[key] = value; + } + } + }, + }, + + actions: { + merge(ctx, settings) { + if (settings == null) return; + for (const [key, value] of Object.entries(settings)) { + ctx.commit('set', { key, value }); + } + }, + + set(ctx, x) { + ctx.commit('set', x); + + if (ctx.rootGetters.isSignedIn) { + os.api('i/update-client-setting', { + name: x.key, + value: x.value + }); + } + }, + + setWidgets(ctx, widgets) { + ctx.state.widgets = widgets; + ctx.dispatch('updateWidgets'); + }, + + addWidget(ctx, widget) { + ctx.state.widgets.unshift(widget); + ctx.dispatch('updateWidgets'); + }, + + removeWidget(ctx, widget) { + ctx.state.widgets = ctx.state.widgets.filter(w => w.id != widget.id); + ctx.dispatch('updateWidgets'); + }, + + updateWidget(ctx, x) { + const w = ctx.state.widgets.find(w => w.id == x.id); + if (w) { + w.data = x.data; + ctx.dispatch('updateWidgets'); + } + }, + + updateWidgets(ctx) { + const widgets = ctx.state.widgets; + ctx.commit('set', { + key: 'widgets', + value: widgets + }); + os.api('i/update-client-setting', { + name: 'widgets', + value: widgets + }); + }, + } + } + } +}); diff --git a/src/client/style.scss b/src/client/style.scss new file mode 100644 index 0000000000..5f90a7aa14 --- /dev/null +++ b/src/client/style.scss @@ -0,0 +1,341 @@ +@charset "utf-8"; + +:root { + --radius: 8px; + --marginFull: 16px; + --marginHalf: 8px; + + --margin: var(--marginFull); + + @media (max-width: 500px) { + --margin: var(--marginHalf); + } +} + +* { + tap-highlight-color: transparent; + -webkit-tap-highlight-color: transparent; +} + +html { + background-color: var(--bg); + background-attachment: fixed; + background-size: cover; + background-position: center; + color: var(--fg); + overflow: auto; + overflow-y: scroll; + + &, * { + scrollbar-color: var(--scrollbarHandle) var(--panel); + + &:hover { + scrollbar-color: var(--scrollbarHandleHover) var(--panel); + } + + &:active { + scrollbar-color: var(--accent) var(--panel); + } + + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + &::-webkit-scrollbar-track { + background: var(--panel); + } + + &::-webkit-scrollbar-thumb { + background: var(--scrollbarHandle); + + &:hover { + background: var(--scrollbarHandleHover); + } + + &:active { + background: var(--accent); + } + } + } +} + +html.changing-theme { + &, * { + transition: background 1s ease !important; + } +} + +body { + overflow-wrap: break-word; +} + +#ini { + position: fixed; + z-index: 1; + top: 0; + left: 0; + width: 100%; + height: 100%; + cursor: wait; + + > svg { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + margin: auto; + width: 64px; + height: 64px; + animation: ini 0.6s infinite linear; + } +} + +html, body { + margin: 0; + padding: 0; + scroll-behavior: smooth; + text-size-adjust: 100%; + font-family: Roboto, HelveticaNeue, Arial, sans-serif; +} + +a { + text-decoration: none; + cursor: pointer; + color: inherit; + + &:hover { + text-decoration: underline; + } + + * { + cursor: pointer; + } +} + +#nprogress { + pointer-events: none; + position: absolute; + z-index: 10000; + + .bar { + background: var(--accent); + position: fixed; + z-index: 10001; + top: 0; + left: 0; + width: 100%; + height: 2px; + } + + .peg { + display: block; + position: absolute; + right: 0; + width: 100px; + height: 100%; + box-shadow: 0 0 10px var(--accent), 0 0 5px var(--accent); + opacity: 1; + transform: rotate(3deg) translate(0px, -4px); + } +} + +#wait { + display: block; + position: fixed; + z-index: 10000; + top: 15px; + right: 15px; + + &:before { + content: ""; + display: block; + width: 18px; + height: 18px; + box-sizing: border-box; + border: solid 2px transparent; + border-top-color: var(--accent); + border-left-color: var(--accent); + border-radius: 50%; + animation: progress-spinner 400ms linear infinite; + } +} + +._button { + appearance: none; + padding: 0; + background: none; + border: none; + cursor: pointer; + color: var(--fg); + touch-action: manipulation; + font-size: 1em; + + &, * { + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; + } + + * { + pointer-events: none; + } + + &:focus { + outline: none; + } + + &:disabled { + opacity: 0.5; + cursor: default; + } +} + +._buttonPrimary { + @extend ._button; + color: #fff; + background: var(--accent); + + &:not(:disabled):hover { + background: var(--jkhztclx); + } + + &:not(:disabled):active { + background: var(--zbqjwygh); + } +} + +._textButton { + @extend ._button; + color: var(--accent); + + &:not(:disabled):hover { + text-decoration: underline; + } +} + +._shadow { + box-shadow: 0 8px 32px var(--shadow); + + @media (max-width: 700px) { + box-shadow: 0 4px 16px var(--shadow); + } + + @media (max-width: 500px) { + box-shadow: 0 2px 8px var(--shadow); + } +} + +._panel { + @extend ._shadow; + position: relative; + background: var(--panel); + border-radius: var(--radius); +} + +._section { + @extend ._panel; + + & + ._section { + margin-top: var(--margin); + } + + > ._title { + margin: 0; + padding: 22px 32px; + font-size: 1.1em; + border-bottom: solid 1px var(--divider); + font-weight: bold; + + @media (max-width: 500px) { + padding: 16px; + font-size: 1em; + } + } + + > ._content { + padding: 32px; + + @media (max-width: 500px) { + padding: 16px; + } + + & + ._content { + border-top: solid 1px var(--divider); + } + + &._list { + padding: 16px; + + @media (max-width: 500px) { + padding: 8px; + } + + ._listItem { + padding: 8px 16px; + border-radius: var(--radius); + + @media (max-width: 500px) { + padding: 8px; + } + + &:hover { + background: var(--listItemHoverBg); + } + + > * { + pointer-events: none; + } + } + } + } + + > ._footer { + border-top: solid 1px var(--divider); + padding: 24px 32px; + + @media (max-width: 500px) { + padding: 16px; + } + } +} + +.zoom-enter-active, .zoom-leave-active { + transition: opacity 0.5s, transform 0.5s !important; +} +.zoom-enter, .zoom-leave-to { + opacity: 0; + transform: scale(0.9); +} + +.zoom-in-top-enter-active, +.zoom-in-top-leave-active { + opacity: 1; + transform: scaleY(1); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); + transform-origin: center top; +} +.zoom-in-top-enter, +.zoom-in-top-leave-active { + opacity: 0; + transform: scaleY(0); +} + +@keyframes progress-spinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +@keyframes ini { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/src/client/style.styl b/src/client/style.styl deleted file mode 100644 index 9638ea5305..0000000000 --- a/src/client/style.styl +++ /dev/null @@ -1,43 +0,0 @@ -@charset "utf-8" - -/* - ::selection - background var(--primary) - color #fff -*/ - -* - position relative - box-sizing border-box - background-clip padding-box !important - tap-highlight-color transparent - -webkit-tap-highlight-color transparent - -html, body - margin 0 - padding 0 - scroll-behavior smooth - text-size-adjust 100% - font-family Roboto, HelveticaNeue, Arial, sans-serif - -html.changing-theme - &, * - transition background 1s ease !important - -a - text-decoration none - color var(--link) - cursor pointer - - &:hover - text-decoration underline - - * - cursor pointer - -@css { - a { - tap-highlight-color: var(--linkTapHighlight) !important; - -webkit-tap-highlight-color: var(--linkTapHighlight) !important; - } -} diff --git a/src/client/app/sw.js b/src/client/sw.js index d080130e3d..a84647b656 100644 --- a/src/client/app/sw.js +++ b/src/client/sw.js @@ -2,7 +2,7 @@ * Service Worker */ -import composeNotification from './common/scripts/compose-notification'; +import composeNotification from './scripts/compose-notification'; // eslint-disable-next-line no-undef const version = _VERSION_; @@ -18,10 +18,7 @@ self.addEventListener('install', ev => { caches.open(cacheName) .then(cache => { return cache.addAll([ - "/", - `/assets/desktop.${version}.js`, - `/assets/mobile.${version}.js`, - "/assets/error.jpg" + '/assets/error.jpg' ]); }) .then(() => self.skipWaiting()) @@ -48,7 +45,7 @@ self.addEventListener('fetch', ev => { return response || fetch(ev.request); }) .catch(() => { - return caches.match("/"); + return caches.match('/'); }) ); }); diff --git a/src/client/theme.ts b/src/client/theme.ts new file mode 100644 index 0000000000..644ab2c989 --- /dev/null +++ b/src/client/theme.ts @@ -0,0 +1,100 @@ +import * as tinycolor from 'tinycolor2'; + +export type Theme = { + id: string; + name: string; + author: string; + desc?: string; + base?: 'dark' | 'light'; + props: { [key: string]: string }; +}; + +export const lightTheme: Theme = require('./themes/light.json5'); +export const darkTheme: Theme = require('./themes/dark.json5'); + +export const builtinThemes = [ + lightTheme, + darkTheme, + require('./themes/lavender.json5'), + require('./themes/halloween.json5'), + require('./themes/garden.json5'), + require('./themes/mauve.json5'), + require('./themes/elegant.json5'), + require('./themes/rainy.json5'), + require('./themes/urban.json5'), + require('./themes/cafe.json5'), +]; + +let timeout = null; + +export function applyTheme(theme: Theme, persist = true) { + if (timeout) clearTimeout(timeout); + + document.documentElement.classList.add('changing-theme'); + + timeout = setTimeout(() => { + document.documentElement.classList.remove('changing-theme'); + }, 1000); + + // Deep copy + const _theme = JSON.parse(JSON.stringify(theme)); + + if (_theme.base) { + const base = [lightTheme, darkTheme].find(x => x.id == _theme.base); + _theme.props = Object.assign({}, base.props, _theme.props); + } + + const props = compile(_theme); + + for (const tag of document.head.children) { + if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { + tag.setAttribute('content', props['accent']); + break; + } + } + + for (const [k, v] of Object.entries(props)) { + document.documentElement.style.setProperty(`--${k}`, v.toString()); + } + + if (persist) { + localStorage.setItem('theme', JSON.stringify(props)); + } +} + +function compile(theme: Theme): { [key: string]: string } { + function getColor(code: string): tinycolor.Instance { + // ref + if (code[0] == '@') { + return getColor(theme.props[code.substr(1)]); + } + + // func + if (code[0] == ':') { + const parts = code.split('<'); + const func = parts.shift().substr(1); + const arg = parseFloat(parts.shift()); + const color = getColor(parts.join('<')); + + switch (func) { + case 'darken': return color.darken(arg); + case 'lighten': return color.lighten(arg); + case 'alpha': return color.setAlpha(arg); + } + } + + return tinycolor(code); + } + + const props = {}; + + for (const [k, v] of Object.entries(theme.props)) { + props[k] = genValue(getColor(v)); + } + + return props; +} + +function genValue(c: tinycolor.Instance): string { + return c.toRgbString(); +} diff --git a/src/client/themes/cafe.json5 b/src/client/themes/cafe.json5 index 084f69299c..b86ea3f6ec 100644 --- a/src/client/themes/cafe.json5 +++ b/src/client/themes/cafe.json5 @@ -6,16 +6,15 @@ base: 'light', - vars: { - primary: 'rgb(234, 154, 82)', - secondary: 'rgb(238, 236, 232)', - text: 'rgb(149, 143, 139)', - }, - props: { - renoteGradient: '#ffe1c7', - renoteText: '$primary', - quoteBorder: '$primary', - mfmMention: '#56907b', + accent: 'rgb(234, 154, 82)', + bg: '#DDD9D1', + fg: 'rgb(149, 143, 139)', + panel: '#EEECE8', + renote: '@accent', + link: '@accent', + mention: '@accent', + hashtag: '@accent', + inputBorder: 'rgba(0, 0, 0, 0.1)', }, } diff --git a/src/client/themes/dark.json5 b/src/client/themes/dark.json5 index 0665d59901..3bb56c8ae3 100644 --- a/src/client/themes/dark.json5 +++ b/src/client/themes/dark.json5 @@ -6,237 +6,59 @@ desc: 'Default dark theme', kind: 'dark', - vars: { - primary: '#fb4e4e', - secondary: '#282C37', - text: '#d6dae0', - }, - props: { - primary: '$primary', - primaryForeground: '#fff', - secondary: '$secondary', - bg: ':darken<8<$secondary', - text: '$text', - textHighlighted: ':lighten<7<$text', - - scrollbarTrack: ':darken<5<$secondary', - scrollbarHandle: ':lighten<5<$secondary', - scrollbarHandleHover: ':lighten<10<$secondary', - - link: '$primary', - linkTapHighlight: ':alpha<0.7<@link', - - notificationIndicator: '$primary', - - switchActive: '$primary', - switchActiveTrack: ':alpha<0.4<@switchActive', - radioActive: '$primary', - - face: '$secondary', - faceText: '#fff', - faceHeader: ':lighten<5<$secondary', - faceHeaderText: '$text', - faceDivider: 'rgba(0, 0, 0, 0.3)', - faceTextButton: '$text', - faceTextButtonHover: ':lighten<10<$text', - faceTextButtonActive: ':darken<10<$text', - faceClearButtonHover: 'rgba(0, 0, 0, 0.1)', - faceClearButtonActive: 'rgba(0, 0, 0, 0.2)', - popupBg: ':lighten<5<$secondary', - popupFg: '$text', - - subNoteBg: 'rgba(0, 0, 0, 0.18)', - subNoteText: ':alpha<0.7<$text', - renoteGradient: '#314027', - renoteText: '#9dbb00', - quoteBorder: '#4e945e', - noteText: '#fff', - noteHeaderName: '#fff', - noteHeaderBadgeFg: '#758188', - noteHeaderBadgeBg: 'rgba(0, 0, 0, 0.25)', - noteHeaderAdminFg: '#f15f71', - noteHeaderAdminBg: '#5d282e', - noteHeaderAcct: ':alpha<0.65<$text', - noteHeaderInfo: ':alpha<0.5<$text', - - noteActions: ':alpha<0.45<$text', - noteActionsHover: ':alpha<0.6<$text', - noteActionsReplyHover: '#0af', - noteActionsRenoteHover: '#8d0', - noteActionsReactionHover: '#fa0', - noteActionsHighlighted: ':alpha<0.7<$text', - - noteAttachedFile: 'rgba(255, 255, 255, 0.1)', - - modalBackdrop: 'rgba(0, 0, 0, 0.5)', - - dateDividerBg: ':darken<2<$secondary', - dateDividerFg: ':alpha<0.7<$text', - - switchTrack: 'rgba(255, 255, 255, 0.15)', - radioBorder: 'rgba(255, 255, 255, 0.6)', - inputBorder: 'rgba(255, 255, 255, 0.7)', - inputLabel: 'rgba(255, 255, 255, 0.7)', - inputText: '#fff', - - buttonBg: 'rgba(255, 255, 255, 0.05)', - buttonHoverBg: 'rgba(255, 255, 255, 0.1)', - buttonActiveBg: 'rgba(255, 255, 255, 0.15)', - - autocompleteItemHoverBg: 'rgba(255, 255, 255, 0.1)', - autocompleteItemText: 'rgba(255, 255, 255, 0.8)', - autocompleteItemTextSub: 'rgba(255, 255, 255, 0.3)', - - cwButtonBg: '#687390', - cwButtonFg: '#393f4f', - cwButtonHoverBg: '#707b97', - - reactionPickerButtonHoverBg: 'rgba(255, 255, 255, 0.18)', - - reactionViewerButtonBg: 'rgba(255, 255, 255, 0.1)', - reactionViewerButtonHoverBg: 'rgba(255, 255, 255, 0.2)', - - pollEditorInputBg: 'rgba(0, 0, 0, 0.25)', - - pollChoiceText: '#fff', - pollChoiceBorder: 'rgba(255, 255, 255, 0.1)', - - urlPreviewBorder: 'rgba(0, 0, 0, 0.4)', - urlPreviewBorderHover: 'rgba(255, 255, 255, 0.2)', - urlPreviewTitle: '$text', - urlPreviewText: ':alpha<0.7<$text', - urlPreviewInfo: ':alpha<0.8<$text', - - calendarWeek: '#43d5dc', - calendarSaturdayOrSunday: '#ff6679', - calendarDay: '$text', - - materBg: 'rgba(0, 0, 0, 0.3)', - - chartCaption: ':alpha<0.6<$text', - - announcementsBg: '#253a50', - announcementsTitle: '#539eff', - announcementsText: '#fff', - - googleSearchBg: 'rgba(0, 0, 0, 0.2)', - googleSearchFg: '#dee4e8', - googleSearchBorder: 'rgba(255, 255, 255, 0.2)', - googleSearchHoverBorder: 'rgba(255, 255, 255, 0.3)', - googleSearchHoverButton: 'rgba(255, 255, 255, 0.1)', - - mfmTitleBg: 'rgba(0, 0, 0, 0.2)', - mfmQuote: ':alpha<0.7<$text', - mfmQuoteLine: ':alpha<0.6<$text', - mfmUrl: '$primary', - mfmLink: '@mfmUrl', - mfmMention: '$primary', - mfmMentionForeground: '@primaryForeground', - mfmHashtag: '$primary', - - suspendedInfoBg: '#611d1d', - suspendedInfoFg: '#ffb4b4', - remoteInfoBg: '#42321c', - remoteInfoFg: '#ffbd3e', - + accent: '#86b300', + accentDarken: ':darken<10<@accent', + accentLighten: ':lighten<10<@accent', + focus: ':alpha<0.3<@accent', + bg: '#000', + fg: '#c7d1d8', + panel: '#111213', + shadow: 'rgba(0, 0, 0, 0.1)', + header: 'rgba(20, 20, 20, 0.75)', + navBg: '@panel', + navFg: '@fg', + navActive: '@accent', + navIndicator: '@accent', + link: '#44a4c1', + hashtag: '#ff9156', + mention: '@accent', + renote: '#229e82', + modalBg: 'rgba(0, 0, 0, 0.5)', + divider: 'rgba(255, 255, 255, 0.1)', + scrollbarHandle: 'rgba(255, 255, 255, 0.2)', + scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', + dateLabelBg: 'rgba(255, 255, 255, 0.08)', + dateLabelFg: '#fff', infoBg: '#253142', infoFg: '#fff', infoWarnBg: '#42321c', infoWarnFg: '#ffbd3e', - - messagingRoomBg: '@bg', - messagingRoomInfo: '#fff', - messagingRoomDateDividerLine: 'rgba(255, 255, 255, 0.1)', - messagingRoomDateDividerText: 'rgba(255, 255, 255, 0.3)', - messagingRoomMessageInfo: 'rgba(255, 255, 255, 0.4)', - messagingRoomMessageBg: '$secondary', - messagingRoomMessageFg: '#fff', - - driveFileIcon: '$text', - - formButtonBorder: 'rgba(255, 255, 255, 0.1)', - formButtonHoverBg: ':alpha<0.2<$primary', - formButtonHoverBorder: ':alpha<0.5<$primary', - formButtonActiveBg: ':alpha<0.12<$primary', - - desktopHeaderBg: ':lighten<5<$secondary', - desktopHeaderFg: '$text', - desktopHeaderHoverFg: '#fff', - desktopHeaderSearchBg: 'rgba(0, 0, 0, 0.1)', - desktopHeaderSearchHoverBg: 'rgba(255, 255, 255, 0.04)', - desktopHeaderSearchFg: '#fff', - desktopNotificationBg: ':alpha<0.9<$secondary', - desktopNotificationFg: ':alpha<0.7<$text', - desktopNotificationShadow: 'rgba(0, 0, 0, 0.4)', - desktopPostFormBg: '@face', - desktopPostFormTextareaBg: 'rgba(0, 0, 0, 0.25)', - desktopPostFormTextareaFg: '#fff', - desktopPostFormTransparentButtonFg: '$primary', - desktopPostFormTransparentButtonActiveGradientStart: ':darken<8<$secondary', - desktopPostFormTransparentButtonActiveGradientEnd: ':darken<3<$secondary', - desktopRenoteFormFooter: ':lighten<5<$secondary', - desktopTimelineHeaderShadow: 'rgba(0, 0, 0, 0.15)', - desktopTimelineSrc: '@faceTextButton', - desktopTimelineSrcHover: '@faceTextButtonHover', - desktopWindowTitle: '@faceHeaderText', - desktopWindowShadow: 'rgba(0, 0, 0, 0.5)', - desktopDriveBg: '@bg', - desktopDriveFolderBg: ':alpha<0.2<$primary', - desktopDriveFolderHoverBg: ':alpha<0.3<$primary', - desktopDriveFolderActiveBg: ':alpha<0.3<:darken<10<$primary', - desktopDriveFolderFg: '#fff', - desktopSettingsNavItem: ':alpha<0.8<$text', - desktopSettingsNavItemHover: ':lighten<10<$text', - - deckAcrylicColumnBg: 'rgba(0, 0, 0, 0.25)', - deckColumnBg: ':darken<3<@face', - - mobileHeaderBg: ':lighten<5<$secondary', - mobileHeaderFg: '$text', - mobileNavBackdrop: 'rgba(0, 0, 0, 0.7)', - mobilePostFormDivider: 'rgba(0, 0, 0, 0.2)', - mobilePostFormTextareaBg: 'rgba(0, 0, 0, 0.3)', - mobilePostFormButton: '$text', - mobileDriveNavBg: ':alpha<0.75<$secondary', - mobileHomeTlItemHover: 'rgba(255, 255, 255, 0.1)', - mobileUserPageName: '#fff', - mobileUserPageAcct: '$text', - mobileUserPageDescription: '$text', - mobileUserPageFollowedBg: 'rgba(0, 0, 0, 0.3)', - mobileUserPageFollowedFg: '$text', - mobileUserPageStatusHighlight: '#fff', - mobileUserPageHeaderShadow: 'rgba(0, 0, 0, 0.3)', - mobileAnnouncement: 'rgba(30, 129, 216, 0.2)', - mobileAnnouncementFg: '#fff', - mobileSignedInAsBg: '#273c34', - mobileSignedInAsFg: '#49ab63', - mobileSignoutBg: '#652222', - mobileSignoutFg: '#ff5f56', - - reversiBannerGradientStart: '#45730e', - reversiBannerGradientEnd: '#464300', - reversiDescBg: 'rgba(255, 255, 255, 0.1)', - reversiListItemShadow: 'rgba(0, 0, 0, 0.7)', - reversiMapSelectBorder: 'rgba(255, 255, 255, 0.1)', - reversiMapSelectHoverBorder: 'rgba(255, 255, 255, 0.2)', - reversiRoomFormShadow: 'rgba(0, 0, 0, 0.7)', - reversiRoomFooterBg: ':alpha<0.9<$secondary', - reversiGameHeaderLine: ':alpha<0.5<$secondary', - reversiGameEmptyCell: ':lighten<2<$secondary', - reversiGameEmptyCellMyTurn: ':lighten<5<$secondary', - reversiGameEmptyCellCanPut: ':lighten<4<$secondary', - - adminDashboardHeaderFg: ':alpha<0.9<$text', - adminDashboardHeaderBorder: 'rgba(0, 0, 0, 0.3)', - adminDashboardCardBg: '$secondary', - adminDashboardCardFg: '$text', - adminDashboardCardDivider: 'rgba(0, 0, 0, 0.3)', - - pageBlockBorder: 'rgba(255, 255, 255, 0.1)', - pageBlockBorderHover: 'rgba(255, 255, 255, 0.15)', - - groupUserListOwnerFg: '#f15f71', - groupUserListOwnerBg: '#5d282e' + cwBg: '#687390', + cwFg: '#393f4f', + cwHoverBg: '#707b97', + toastBg: 'rgba(0, 0, 0, 0.5)', + toastFg: '#c7d1d8', + buttonBg: 'rgba(255, 255, 255, 0.05)', + buttonHoverBg: 'rgba(255, 255, 255, 0.1)', + inputBorder: '#959da2', + listItemHoverBg: 'rgba(255, 255, 255, 0.03)', + driveFolderBg: ':alpha<0.3<@accent', + bonzsgfz: ':alpha<0<@bg', + pcncwizz: ':darken<2<@panel', + vocsgcxy: 'rgba(0, 0, 0, 0.5)', + yrnqrguo: 'rgba(255, 255, 255, 0.05)', + mkykhqkw: ':lighten<3<@fg', + nwjktjjq: 'rgba(255, 255, 255, 0.1)', + geavgsxy: 'rgba(255, 255, 255, 0.05)', + nhzhphzx: 'rgba(255, 255, 255, 0.15)', + tyvedwbe: 'rgba(0, 0, 0, 0.5)', + bwqtlupy: 'rgba(255, 255, 255, 0.05)', + jkhztclx: ':lighten<5<@accent', + zbqjwygh: ':darken<5<@accent', + xxubwiul: ':alpha<0.4<@accent', + aupeazdm: 'rgba(0, 0, 0, 0.3)', + jvhmlskx: 'rgba(255, 255, 255, 0.1)', + yakfpmhl: 'rgba(255, 255, 255, 0.15)', }, } diff --git a/src/client/themes/elegant.json5 b/src/client/themes/elegant.json5 new file mode 100644 index 0000000000..52ae4cbfa0 --- /dev/null +++ b/src/client/themes/elegant.json5 @@ -0,0 +1,18 @@ +{ + id: 'de465368-9dd1-4bba-b1b9-69f312fcf6e7', + + name: 'Elegant', + author: 'syuilo', + desc: 'Inspired by meimei\'s theme', + + base: 'dark', + + props: { + accent: 'rgb(187, 146, 45)', + panel: 'rgb(76, 33, 33)', + bg: 'rgb(53, 21, 21)', + fg: 'rgb(216, 210, 199)', + header: 'rgba(76, 45, 33, 0.75)', + renote: '@accent', + }, +} diff --git a/src/client/themes/future.json5 b/src/client/themes/future.json5 deleted file mode 100644 index c89b90fae7..0000000000 --- a/src/client/themes/future.json5 +++ /dev/null @@ -1,39 +0,0 @@ -{ - id: 'bb5a8287-a072-4b0a-8ae5-ea2a0d33f4f2', - - name: 'Future', - author: 'syuilo', - desc: 'Sci-fi flavored', - - base: 'dark', - - vars: { - c0: '#0e0e0e', - c1: 'rgb(255, 105, 78)', - c2: 'rgb(99, 197, 210)', - c4: 'rgb(253, 254, 214)', - c3: 'rgb(204, 254, 253)', - primary: '$c1', - secondary: '#191919', - text: '$c3', - }, - - props: { - bg: '$c0', - noteText: '$c4', - noteHeaderAcct: ':alpha<0.65<$c4', - noteHeaderInfo: ':alpha<0.5<$c4', - subNoteText: ':alpha<0.7<$c4', - renoteGradient: '$secondary', - renoteText: '$c2', - quoteBorder: '$c2', - mfmHashtag: '$c1', - mfmUrl: '$c2', - mfmLink: '$c2', - mfmMention: '$c1', - mfmMentionForeground: '#fff', - notificationIndicator: '$c2', - link: '$c2', - desktopHeaderBg: '$secondary', - }, -} diff --git a/src/client/themes/garden.json5 b/src/client/themes/garden.json5 new file mode 100644 index 0000000000..ae12fb3e78 --- /dev/null +++ b/src/client/themes/garden.json5 @@ -0,0 +1,17 @@ +{ + id: '45b13782-9143-4dd8-ac0c-4a872321fc63', + + name: 'Garden', + author: 'syuilo', + + base: 'light', + + props: { + accent: 'rgb(147, 206, 188)', + bg: 'rgb(253, 245, 242)', + fg: 'rgb(161, 147, 139)', + renote: '@accent', + hashtag: 'rgb(226, 152, 48)', + link: '#aac12c', + }, +} diff --git a/src/client/themes/gray.json5 b/src/client/themes/gray.json5 deleted file mode 100644 index 59494f278a..0000000000 --- a/src/client/themes/gray.json5 +++ /dev/null @@ -1,21 +0,0 @@ -{ - id: '56ff14eb-1e6d-4c0c-9e84-71eb156234e5', - - name: 'Gray', - author: 'syuilo', - - base: 'light', - - vars: { - primary: '#C03233', - secondary: 'rgb(213, 213, 213)', - text: 'rgb(102, 102, 102)', - }, - - props: { - renoteGradient: '#bdbdbd', - renoteText: '$primary', - quoteBorder: '$primary', - desktopPostFormBg: '#ececec', - }, -} diff --git a/src/client/themes/gruvbox-dark.json5 b/src/client/themes/gruvbox-dark.json5 deleted file mode 100644 index 2d03153190..0000000000 --- a/src/client/themes/gruvbox-dark.json5 +++ /dev/null @@ -1,29 +0,0 @@ -{ - id: '0c6e70e2-a1ec-4053-9b1a-b6082fe016cb', - - name: 'Gruvbox Dark', - author: 'syuilo', - - base: 'dark', - - vars: { - primary: 'rgb(215, 153, 33)', - secondary: 'rgb(40, 40, 40)', - text: 'rgb(235, 219, 178)', - }, - - props: { - renoteGradient: '#58581e', - renoteText: 'rgb(169, 174, 36)', - quoteBorder: 'rgb(169, 174, 36)', - mfmMention: 'rgb(177, 98, 134)', - mfmMentionForeground: '#fff', - mfmUrl: 'rgb(69, 133, 136)', - mfmLink: 'rgb(104, 157, 106)', - mfmHashtag: 'rgb(251, 73, 52)', - notificationIndicator: 'rgb(184, 187, 38)', - switchActive: 'rgb(254, 128, 25)', - radioActive: 'rgb(131, 165, 152)', - link: 'rgb(104, 157, 106)', - }, -} diff --git a/src/client/themes/halloween.json5 b/src/client/themes/halloween.json5 index 608105903a..1394c793ed 100644 --- a/src/client/themes/halloween.json5 +++ b/src/client/themes/halloween.json5 @@ -7,15 +7,11 @@ base: 'dark', - vars: { - primary: '#d67036', - secondary: '#1f1d30', - text: '#b1bee3', - }, - props: { - renoteGradient: '#5d2d1a', - renoteText: '#ff6c00', - quoteBorder: '#c3631c', + accent: '#d67036', + panel: '#1f1d30', + bg: '#0f0e17', + fg: '#b1bee3', + renote: '@accent', }, } diff --git a/src/client/themes/japanese-sushi-set.json5 b/src/client/themes/japanese-sushi-set.json5 deleted file mode 100644 index 94edecca52..0000000000 --- a/src/client/themes/japanese-sushi-set.json5 +++ /dev/null @@ -1,20 +0,0 @@ -{ - id: '2b0a0654-cdb4-4c9a-8244-736b647d3c2a', - - name: 'Japanese Sushi Set', - author: 'Noizenecio', - - base: 'dark', - - vars: { - primary: 'rgb(234, 136, 50)', - secondary: 'rgb(34, 36, 42)', - text: 'rgb(221, 209, 203)', - }, - - props: { - renoteGradient: '#6d3d14', - renoteText: '$primary', - quoteBorder: '$primary', - }, -} diff --git a/src/client/themes/lavender.json5 b/src/client/themes/lavender.json5 index e3078ad516..4eb4a54749 100644 --- a/src/client/themes/lavender.json5 +++ b/src/client/themes/lavender.json5 @@ -2,19 +2,18 @@ id: 'e9c8c01d-9c15-48d0-9b5c-3d00843b5b36', name: 'Lavender', - author: 'sokuyuku', + author: 'syuilo', base: 'light', - vars: { - primary: 'rgb(206, 147, 191)', - secondary: 'rgb(253, 242, 243)', - text: 'rgb(161, 139, 146)', - }, - props: { - renoteGradient: '#f7e4ec', - renoteText: '$primary', - quoteBorder: '$primary', + accent: 'rgb(206, 147, 191)', + bg: 'rgb(253, 242, 243)', + fg: 'rgb(161, 139, 146)', + renote: '@accent', + link: '@accent', + mention: '@accent', + hashtag: '@accent', + dateLabelBg: 'rgb(204, 186, 188)', }, } diff --git a/src/client/themes/light.json5 b/src/client/themes/light.json5 index cbe456ca5d..075933f350 100644 --- a/src/client/themes/light.json5 +++ b/src/client/themes/light.json5 @@ -6,237 +6,59 @@ desc: 'Default light theme', kind: 'light', - vars: { - primary: '#f18570', - secondary: '#fff', - text: '#666', - }, - props: { - primary: '$primary', - primaryForeground: '#fff', - secondary: '$secondary', - bg: ':darken<8<$secondary', - text: '$text', - textHighlighted: ':darken<7<$text', - - scrollbarTrack: '#fff', - scrollbarHandle: '#00000033', - scrollbarHandleHover: '#00000066', - - link: '$primary', - linkTapHighlight: ':alpha<0.7<@link', - - notificationIndicator: '$primary', - - switchActive: '$primary', - switchActiveTrack: ':alpha<0.4<@switchActive', - radioActive: '$primary', - - face: '$secondary', - faceText: '$text', - faceHeader: ':lighten<5<$secondary', - faceHeaderText: '$text', - faceDivider: 'rgba(0, 0, 0, 0.082)', - faceTextButton: ':alpha<0.7<$text', - faceTextButtonHover: ':alpha<0.7<:darken<7<$text', - faceTextButtonActive: ':alpha<0.7<:darken<10<$text', - faceClearButtonHover: 'rgba(0, 0, 0, 0.025)', - faceClearButtonActive: 'rgba(0, 0, 0, 0.05)', - popupBg: ':lighten<5<$secondary', - popupFg: '$text', - - subNoteBg: 'rgba(0, 0, 0, 0.01)', - subNoteText: ':alpha<0.7<$text', - renoteGradient: '#edfde2', - renoteText: '#9dbb00', - quoteBorder: '#c0dac6', - noteText: '$text', - noteHeaderName: ':darken<2<$text', - noteHeaderBadgeFg: '#aaa', - noteHeaderBadgeBg: 'rgba(0, 0, 0, 0.05)', - noteHeaderAdminFg: '#f15f71', - noteHeaderAdminBg: '#ffdfdf', - noteHeaderAcct: ':alpha<0.7<@noteHeaderName', - noteHeaderInfo: ':alpha<0.7<@noteHeaderName', - - noteActions: ':alpha<0.3<$text', - noteActionsHover: ':alpha<0.9<$text', - noteActionsReplyHover: '#0af', - noteActionsRenoteHover: '#8d0', - noteActionsReactionHover: '#fa0', - noteActionsHighlighted: '#888', - - noteAttachedFile: 'rgba(0, 0, 0, 0.05)', - - modalBackdrop: 'rgba(0, 0, 0, 0.1)', - - dateDividerBg: ':darken<2<$secondary', - dateDividerFg: ':alpha<0.7<$text', - - switchTrack: 'rgba(0, 0, 0, 0.25)', - radioBorder: 'rgba(0, 0, 0, 0.4)', - inputBorder: 'rgba(0, 0, 0, 0.42)', - inputLabel: 'rgba(0, 0, 0, 0.54)', - inputText: '#000', - - buttonBg: 'rgba(0, 0, 0, 0.05)', - buttonHoverBg: 'rgba(0, 0, 0, 0.1)', - buttonActiveBg: 'rgba(0, 0, 0, 0.15)', - - autocompleteItemHoverBg: 'rgba(0, 0, 0, 0.1)', - autocompleteItemText: 'rgba(0, 0, 0, 0.8)', - autocompleteItemTextSub: 'rgba(0, 0, 0, 0.3)', - - cwButtonBg: '#b1b9c1', - cwButtonFg: '#fff', - cwButtonHoverBg: '#bbc4ce', - - reactionPickerButtonHoverBg: '#eee', - - reactionViewerButtonBg: 'rgba(0, 0, 0, 0.05)', - reactionViewerButtonHoverBg: 'rgba(0, 0, 0, 0.1)', - - pollEditorInputBg: '#fff', - - pollChoiceText: '#000', - pollChoiceBorder: 'rgba(0, 0, 0, 0.1)', - - urlPreviewBorder: 'rgba(0, 0, 0, 0.1)', - urlPreviewBorderHover: 'rgba(0, 0, 0, 0.2)', - urlPreviewTitle: '$text', - urlPreviewText: ':alpha<0.7<$text', - urlPreviewInfo: ':alpha<0.8<$text', - - calendarWeek: '#19a2a9', - calendarSaturdayOrSunday: '#ef95a0', - calendarDay: '$text', - - materBg: 'rgba(0, 0, 0, 0.1)', - - chartCaption: ':alpha<0.6<$text', - - announcementsBg: '#f3f9ff', - announcementsTitle: '#4078c0', - announcementsText: '#57616f', - - googleSearchBg: '#fff', - googleSearchFg: '#55595c', - googleSearchBorder: 'rgba(0, 0, 0, 0.2)', - googleSearchHoverBorder: 'rgba(0, 0, 0, 0.3)', - googleSearchHoverButton: 'rgba(0, 0, 0, 0.05)', - - mfmTitleBg: 'rgba(0, 0, 0, 0.07)', - mfmQuote: ':alpha<0.6<$text', - mfmQuoteLine: ':alpha<0.5<$text', - mfmUrl: '$primary', - mfmLink: '@mfmUrl', - mfmMention: '$primary', - mfmMentionForeground: '@primaryForeground', - mfmHashtag: '$primary', - - suspendedInfoBg: '#ffdbdb', - suspendedInfoFg: '#570808', - remoteInfoBg: '#fff0db', - remoteInfoFg: '#573c08', - + accent: '#86b300', + accentDarken: ':darken<10<@accent', + accentLighten: ':lighten<10<@accent', + focus: ':alpha<0.3<@accent', + bg: '#fafafa', + fg: '#5c6a73', + panel: '#fff', + shadow: 'rgba(0, 0, 0, 0.1)', + header: 'rgba(255, 255, 255, 0.75)', + navBg: '@panel', + navFg: '@fg', + navActive: '@accent', + navIndicator: '@accent', + link: '#44a4c1', + hashtag: '#ff9156', + mention: '@accent', + renote: '#229e82', + modalBg: 'rgba(0, 0, 0, 0.3)', + divider: 'rgba(0, 0, 0, 0.1)', + scrollbarHandle: 'rgba(0, 0, 0, 0.2)', + scrollbarHandleHover: 'rgba(0, 0, 0, 0.4)', + dateLabelBg: 'rgba(0, 0, 0, 0.5)', + dateLabelFg: '#fff', infoBg: '#e5f5ff', infoFg: '#72818a', infoWarnBg: '#fff0db', infoWarnFg: '#573c08', - - messagingRoomBg: '#fff', - messagingRoomInfo: '#000', - messagingRoomDateDividerLine: 'rgba(0, 0, 0, 0.1)', - messagingRoomDateDividerText: 'rgba(0, 0, 0, 0.3)', - messagingRoomMessageInfo: 'rgba(0, 0, 0, 0.4)', - messagingRoomMessageBg: '#eee', - messagingRoomMessageFg: '#333', - - driveFileIcon: '$text', - - formButtonBorder: 'rgba(0, 0, 0, 0.1)', - formButtonHoverBg: ':alpha<0.12<$primary', - formButtonHoverBorder: ':alpha<0.3<$primary', - formButtonActiveBg: ':alpha<0.12<$primary', - - desktopHeaderBg: ':lighten<5<$secondary', - desktopHeaderFg: '$text', - desktopHeaderHoverFg: ':darken<7<$text', - desktopHeaderSearchBg: 'rgba(0, 0, 0, 0.05)', - desktopHeaderSearchHoverBg: 'rgba(0, 0, 0, 0.08)', - desktopHeaderSearchFg: '#000', - desktopNotificationBg: ':alpha<0.9<$secondary', - desktopNotificationFg: ':alpha<0.7<$text', - desktopNotificationShadow: 'rgba(0, 0, 0, 0.2)', - desktopPostFormBg: ':lighten<33<$primary', - desktopPostFormTextareaBg: '#fff', - desktopPostFormTextareaFg: '#333', - desktopPostFormTransparentButtonFg: ':alpha<0.5<$primary', - desktopPostFormTransparentButtonActiveGradientStart: ':lighten<30<$primary', - desktopPostFormTransparentButtonActiveGradientEnd: ':lighten<33<$primary', - desktopRenoteFormFooter: ':lighten<33<$primary', - desktopTimelineHeaderShadow: 'rgba(0, 0, 0, 0.08)', - desktopTimelineSrc: '$text', - desktopTimelineSrcHover: ':darken<7<$text', - desktopWindowTitle: '$text', - desktopWindowShadow: 'rgba(0, 0, 0, 0.2)', - desktopDriveBg: '#fff', - desktopDriveFolderBg: ':lighten<31<$primary', - desktopDriveFolderHoverBg: ':lighten<27<$primary', - desktopDriveFolderActiveBg: ':lighten<25<$primary', - desktopDriveFolderFg: ':darken<10<$primary', - desktopSettingsNavItem: ':alpha<0.8<$text', - desktopSettingsNavItemHover: ':darken<10<$text', - - deckAcrylicColumnBg: 'rgba(0, 0, 0, 0.1)', - deckColumnBg: ':darken<4<@face', - - mobileHeaderBg: ':lighten<5<$secondary', - mobileHeaderFg: '$text', - mobileNavBackdrop: 'rgba(0, 0, 0, 0.2)', - mobilePostFormDivider: 'rgba(0, 0, 0, 0.1)', - mobilePostFormTextareaBg: '#fff', - mobilePostFormButton: '$text', - mobileDriveNavBg: ':alpha<0.75<$secondary', - mobileHomeTlItemHover: 'rgba(0, 0, 0, 0.05)', - mobileUserPageName: '#757c82', - mobileUserPageAcct: '#969ea5', - mobileUserPageDescription: '#757c82', - mobileUserPageFollowedBg: '#a7bec7', - mobileUserPageFollowedFg: '#fff', - mobileUserPageStatusHighlight: '#787e86', - mobileUserPageHeaderShadow: 'rgba(0, 0, 0, 0.07)', - mobileAnnouncement: 'rgba(155, 196, 232, 0.2)', - mobileAnnouncementFg: '#3f4967', - mobileSignedInAsBg: '#fcfff5', - mobileSignedInAsFg: '#2c662d', - mobileSignoutBg: '#fff6f5', - mobileSignoutFg: '#cc2727', - - reversiBannerGradientStart: '#8bca3e', - reversiBannerGradientEnd: '#d6cf31', - reversiDescBg: 'rgba(0, 0, 0, 0.1)', - reversiListItemShadow: 'rgba(0, 0, 0, 0.15)', - reversiMapSelectBorder: 'rgba(0, 0, 0, 0.1)', - reversiMapSelectHoverBorder: 'rgba(0, 0, 0, 0.2)', - reversiRoomFormShadow: 'rgba(0, 0, 0, 0.1)', - reversiRoomFooterBg: ':alpha<0.9<$secondary', - reversiGameHeaderLine: '#c4cdd4', - reversiGameEmptyCell: 'rgba(0, 0, 0, 0.06)', - reversiGameEmptyCellMyTurn: 'rgba(0, 0, 0, 0.12)', - reversiGameEmptyCellCanPut: 'rgba(0, 0, 0, 0.09)', - - adminDashboardHeaderFg: ':alpha<0.9<$text', - adminDashboardHeaderBorder: 'rgba(0, 0, 0, 0.1)', - adminDashboardCardBg: '$secondary', - adminDashboardCardFg: '$text', - adminDashboardCardDivider: 'rgba(0, 0, 0, 0.082)', - - pageBlockBorder: 'rgba(0, 0, 0, 0.1)', - pageBlockBorderHover: 'rgba(0, 0, 0, 0.15)', - - groupUserListOwnerFg: '#f15f71', - groupUserListOwnerBg: '#ffdfdf' + cwBg: '#b1b9c1', + cwFg: '#fff', + cwHoverBg: '#bbc4ce', + toastBg: 'rgba(255, 255, 255, 0.5)', + toastFg: '#0c0c0c', + buttonBg: 'rgba(0, 0, 0, 0.05)', + buttonHoverBg: 'rgba(0, 0, 0, 0.1)', + inputBorder: '#dae0e4', + listItemHoverBg: 'rgba(0, 0, 0, 0.03)', + driveFolderBg: ':alpha<0.3<@accent', + bonzsgfz: ':alpha<0<@bg', + pcncwizz: ':darken<2<@panel', + vocsgcxy: 'rgba(255, 255, 255, 0.5)', + yrnqrguo: 'rgba(0, 0, 0, 0.05)', + mkykhqkw: ':darken<3<@fg', + nwjktjjq: 'rgba(0, 0, 0, 0.1)', + geavgsxy: 'rgba(0, 0, 0, 0.05)', + nhzhphzx: 'rgba(0, 0, 0, 0.25)', + tyvedwbe: 'rgba(0, 0, 0, 0.1)', + bwqtlupy: 'rgba(0, 0, 0, 0.05)', + jkhztclx: ':lighten<5<@accent', + zbqjwygh: ':darken<5<@accent', + xxubwiul: ':alpha<0.4<@accent', + aupeazdm: 'rgba(0, 0, 0, 0.1)', + jvhmlskx: 'rgba(0, 0, 0, 0.1)', + yakfpmhl: 'rgba(0, 0, 0, 0.15)', }, } diff --git a/src/client/themes/mauve.json5 b/src/client/themes/mauve.json5 index b2ec28b445..47304c922f 100644 --- a/src/client/themes/mauve.json5 +++ b/src/client/themes/mauve.json5 @@ -1,20 +1,18 @@ { - id: '252b2caf-86c2-4c3f-a73f-e1fc1cfa5298', + id: '6846bcbe-afbe-487c-bece-77e8cfc4ab8a', name: 'Mauve', - author: 'とわこ', + author: 'syuilo', base: 'dark', - vars: { - primary: 'rgb(133, 88, 150)', - secondary: 'rgb(54, 43, 59)', - text: 'rgb(229, 223, 231)', - }, - props: { - renoteGradient: '#54415d', - renoteText: '$primary', - quoteBorder: '$primary', + accent: 'rgb(133, 88, 150)', + panel: 'rgb(54, 43, 59)', + bg: '#201A23', + fg: 'rgb(229, 223, 231)', + shadow: 'rgba(0, 0, 0, 0.2)', + header: 'rgba(87, 70, 97, 0.5)', + renote: '@accent', }, } diff --git a/src/client/themes/monokai.json5 b/src/client/themes/monokai.json5 deleted file mode 100644 index 1ecd68730e..0000000000 --- a/src/client/themes/monokai.json5 +++ /dev/null @@ -1,29 +0,0 @@ -{ - id: 'fef11dc4-6b17-436e-b374-73282c44ddc0', - - name: 'Monokai', - author: 'syuilo', - - base: 'dark', - - vars: { - primary: '#f92672', - secondary: '#272822', - text: '#f8f8f2', - }, - - props: { - renoteGradient: '#3f500f', - renoteText: '#a6e22e', - quoteBorder: '#a6e22e', - mfmMention: '#ae81ff', - mfmMentionForeground: '#fff', - mfmUrl: '#66d9ef', - mfmLink: '#e6db74', - mfmHashtag: '#fd971f', - notificationIndicator: '#66d9ef', - switchActive: 'rgb(166, 226, 46)', - radioActive: '#fd971f', - link: '#e6db74', - }, -} diff --git a/src/client/themes/rainy.json5 b/src/client/themes/rainy.json5 index 26ff3a6c86..0ad6338295 100644 --- a/src/client/themes/rainy.json5 +++ b/src/client/themes/rainy.json5 @@ -1,21 +1,15 @@ { - id: '2058b33e-5127-4e63-ae67-a900f3a11723', + id: '2d7d1479-acb8-4e2e-85bb-565a2d8e6966', name: 'Rainy', author: 'syuilo', - desc: 'It\'s a rainy day.', base: 'light', - vars: { - primary: 'rgb(100, 184, 193)', - secondary: 'rgb(228, 234, 234)', - text: 'rgb(85, 94, 92)', - }, - props: { - renoteGradient: '#bcd0d0', - renoteText: '$primary', - quoteBorder: '$primary', + accent: 'rgb(147, 199, 206)', + bg: 'rgb(220, 229, 232)', + fg: 'rgb(139, 153, 161)', + renote: '@accent', }, } diff --git a/src/client/themes/tweet-deck.json5 b/src/client/themes/tweet-deck.json5 deleted file mode 100644 index aac9e3d009..0000000000 --- a/src/client/themes/tweet-deck.json5 +++ /dev/null @@ -1,44 +0,0 @@ -{ - name: 'Tweet Deck', - id: '06f82fb4-0dad-4d70-8a3f-56cae91e1163', - author: 'simirall', - desc: 'Tweet like a pro.', - base: 'dark', - vars: { - primary: '#1da1f2', - secondary: '#15202b', - text: '#fdfdfd', - }, - props: { - bg: '#10171e', - faceHeader: '$secondary', - faceTextButton: '$primary', - renoteGradient: '$secondary', - renoteText: '#17bf63', - quoteBorder: '#38444d', - noteHeaderAdminFg: '$primary', - noteHeaderAdminBg: '$secondary', - noteActionsReplyHover: '$primary', - noteActionsRenoteHover: '#17bf63', - noteActionsReactionHover: '#e0245e', - calendarWeek: '$primary', - calendarSaturdayOrSunday: '#e0245e', - announcementsBg: '$secondary', - announcementsTitle: '$primary', - suspendedInfoBg: '$secondary', - suspendedInfoFg: '$primary', - remoteInfoBg: '$secondary', - remoteInfoFg: '$primary', - desktopHeaderBg: '#1c2938', - desktopHeaderFg: '#a9adae', - desktopHeaderHoverFg: '#fff', - desktopPostFormTransparentButtonFg: '#a9adae', - desktopTimelineSrc: '$primary', - desktopTimelineSrcHover: '#fff', - deckAcrylicColumnBg: 'rgba(0, 0, 0, 0.0)', - reversiBannerGradientStart: '$primary', - reversiBannerGradientEnd: '$primary', - reversiGameEmptyCellMyTurn: ':lighten<5<$primary', - reversiGameEmptyCellCanPut: ':lighten<4<$primary', - }, -} diff --git a/src/client/themes/urban.json5 b/src/client/themes/urban.json5 new file mode 100644 index 0000000000..342d3b9cab --- /dev/null +++ b/src/client/themes/urban.json5 @@ -0,0 +1,18 @@ +{ + id: 'b9392635-8c3d-4397-aaf7-796e49781899', + + name: 'Urban', + author: 'syuilo', + + base: 'dark', + + props: { + accent: 'rgb(212, 104, 48)', + panel: 'rgb(38, 44, 53)', + bg: 'rgb(26, 29, 33)', + fg: 'rgb(199, 209, 216)', + shadow: 'rgba(0, 0, 0, 0.2)', + header: 'rgba(51, 64, 72, 0.75)', + renote: '@accent', + }, +} diff --git a/src/client/themes/vivid.json5 b/src/client/themes/vivid.json5 deleted file mode 100644 index 27bf742f3f..0000000000 --- a/src/client/themes/vivid.json5 +++ /dev/null @@ -1,23 +0,0 @@ -{ - id: '2d066d6e-bd39-4f23-bd48-686d5c1c6ae8', - - name: 'Vivid', - author: 'syuilo', - - base: 'light', - - vars: { - primary: 'rgb(255, 153, 64)', - secondary: 'rgb(255, 255, 255)', - text: 'rgb(108, 118, 128)', - }, - - props: { - bg: 'rgb(250, 250, 250)', - mfmMention: '#f07171', - mfmMentionForeground: '#fff', - mfmUrl: '#86b300', - mfmLink: '#399ee6', - mfmHashtag: '#fa8d3e' - }, -} diff --git a/src/client/app/tsconfig.json b/src/client/tsconfig.json index 3ec0271f63..3ec0271f63 100644 --- a/src/client/app/tsconfig.json +++ b/src/client/tsconfig.json diff --git a/src/client/app/v.d.ts b/src/client/v.d.ts index b3a21c6cdb..b3a21c6cdb 100644 --- a/src/client/app/v.d.ts +++ b/src/client/v.d.ts diff --git a/src/client/widgets/calendar.vue b/src/client/widgets/calendar.vue new file mode 100644 index 0000000000..ae9dbfecef --- /dev/null +++ b/src/client/widgets/calendar.vue @@ -0,0 +1,206 @@ +<template> +<div class="mkw-calendar" :class="{ _panel: props.design === 0 }"> + <div class="calendar" :data-is-holiday="isHoliday"> + <p class="month-and-year"> + <span class="year">{{ $t('yearX', { year }) }}</span> + <span class="month">{{ $t('monthX', { month }) }}</span> + </p> + <p class="day">{{ $t('dayX', { day }) }}</p> + <p class="week-day">{{ weekDay }}</p> + </div> + <div class="info"> + <div> + <p>{{ $t('today') }}: <b>{{ dayP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${dayP}%` }"></div> + </div> + </div> + <div> + <p>{{ $t('thisMonth') }}: <b>{{ monthP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${monthP}%` }"></div> + </div> + </div> + <div> + <p>{{ $t('thisYear') }}: <b>{{ yearP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${yearP}%` }"></div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import define from './define'; +import i18n from '../i18n'; + +export default define({ + name: 'calendar', + props: () => ({ + design: 0 + }) +}).extend({ + i18n, + data() { + return { + now: new Date(), + year: null, + month: null, + day: null, + weekDay: null, + yearP: null, + dayP: null, + monthP: null, + isHoliday: null, + clock: null + }; + }, + created() { + this.tick(); + this.clock = setInterval(this.tick, 1000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + func() { + if (this.props.design == 2) { + this.props.design = 0; + } else { + this.props.design++; + } + this.save(); + }, + tick() { + const now = new Date(); + const nd = now.getDate(); + const nm = now.getMonth(); + const ny = now.getFullYear(); + + this.year = ny; + this.month = nm + 1; + this.day = nd; + this.weekDay = [ + this.$t('_weekday.sunday'), + this.$t('_weekday.monday'), + this.$t('_weekday.tuesday'), + this.$t('_weekday.wednesday'), + this.$t('_weekday.thursday'), + this.$t('_weekday.friday'), + this.$t('_weekday.saturday') + ][now.getDay()]; + + const dayNumer = now.getTime() - new Date(ny, nm, nd).getTime(); + const dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/; + const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime(); + const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime(); + const yearNumer = now.getTime() - new Date(ny, 0, 1).getTime(); + const yearDenom = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime(); + + this.dayP = dayNumer / dayDenom * 100; + this.monthP = monthNumer / monthDenom * 100; + this.yearP = yearNumer / yearDenom * 100; + + this.isHoliday = now.getDay() == 0 || now.getDay() == 6; + } + } +}); +</script> + +<style lang="scss" scoped> +.mkw-calendar { + padding: 16px 0; + + &:after { + content: ""; + display: block; + clear: both; + } + + > .calendar { + float: left; + width: 60%; + text-align: center; + + &[data-is-holiday] { + > .day { + color: #ef95a0; + } + } + + > p { + margin: 0; + line-height: 18px; + font-size: 14px; + + > span { + margin: 0 4px; + } + } + + > .day { + margin: 10px 0; + line-height: 32px; + font-size: 28px; + } + } + + > .info { + display: block; + float: left; + width: 40%; + padding: 0 16px 0 0; + box-sizing: border-box; + + > div { + margin-bottom: 8px; + + &:last-child { + margin-bottom: 4px; + } + + > p { + margin: 0 0 2px 0; + font-size: 12px; + line-height: 18px; + opacity: 0.8; + + > b { + margin-left: 2px; + } + } + + > .meter { + width: 100%; + overflow: hidden; + background: var(--aupeazdm); + border-radius: 8px; + + > .val { + height: 4px; + transition: width .3s cubic-bezier(0.23, 1, 0.32, 1); + } + } + + &:nth-child(1) { + > .meter > .val { + background: #f7796c; + } + } + + &:nth-child(2) { + > .meter > .val { + background: #a1de41; + } + } + + &:nth-child(3) { + > .meter > .val { + background: #41ddde; + } + } + } + } +} +</style> diff --git a/src/client/app/common/define-widget.ts b/src/client/widgets/define.ts index ba4deafe3a..768446c128 100644 --- a/src/client/app/common/define-widget.ts +++ b/src/client/widgets/define.ts @@ -9,14 +9,6 @@ export default function <T extends object>(data: { widget: { type: Object }, - column: { - type: Object, - default: null - }, - platform: { - type: String, - required: true - }, isCustomizeMode: { type: Boolean, default: false @@ -59,11 +51,7 @@ export default function <T extends object>(data: { }, save() { - if (this.platform == 'deck') { - this.$store.commit('updateDeckColumn', this.column); - } else { - this.$store.commit('updateWidget', this.widget); - } + this.$store.dispatch('settings/updateWidget', this.widget); } } }); diff --git a/src/client/widgets/index.ts b/src/client/widgets/index.ts new file mode 100644 index 0000000000..4743be0763 --- /dev/null +++ b/src/client/widgets/index.ts @@ -0,0 +1,8 @@ +import Vue from 'vue'; + +Vue.component('mkw-memo', () => import('./memo.vue').then(m => m.default)); +Vue.component('mkw-notifications', () => import('./notifications.vue').then(m => m.default)); +Vue.component('mkw-timeline', () => import('./timeline.vue').then(m => m.default)); +Vue.component('mkw-calendar', () => import('./calendar.vue').then(m => m.default)); +Vue.component('mkw-rss', () => import('./rss.vue').then(m => m.default)); +Vue.component('mkw-trends', () => import('./trends.vue').then(m => m.default)); diff --git a/src/client/widgets/memo.vue b/src/client/widgets/memo.vue new file mode 100644 index 0000000000..974c13eb0d --- /dev/null +++ b/src/client/widgets/memo.vue @@ -0,0 +1,120 @@ +<template> +<div> + <mk-container :show-header="!props.compact"> + <template #header><fa :icon="faStickyNote"/>{{ $t('_widgets.memo') }}</template> + + <div class="otgbylcu"> + <textarea v-model="text" :placeholder="$t('placeholder')" @input="onChange"></textarea> + <button @click="saveMemo" :disabled="!changed">{{ $t('save') }}</button> + </div> + </mk-container> +</div> +</template> + +<script lang="ts"> +import { faStickyNote } from '@fortawesome/free-solid-svg-icons'; +import MkContainer from '../components/ui/container.vue'; +import define from './define'; +import i18n from '../i18n'; + +export default define({ + name: 'memo', + props: () => ({ + compact: false + }) +}).extend({ + i18n, + + components: { + MkContainer + }, + + data() { + return { + text: null, + changed: false, + timeoutId: null, + faStickyNote + }; + }, + + created() { + this.text = this.$store.state.settings.memo; + + this.$watch('$store.state.settings.memo', text => { + this.text = text; + }); + }, + + methods: { + func() { + this.props.compact = !this.props.compact; + this.save(); + }, + + onChange() { + this.changed = true; + clearTimeout(this.timeoutId); + this.timeoutId = setTimeout(this.saveMemo, 1000); + }, + + saveMemo() { + this.$store.dispatch('settings/set', { + key: 'memo', + value: this.text + }); + this.changed = false; + } + } +}); +</script> + +<style lang="scss" scoped> +.otgbylcu { + padding-bottom: 28px + 16px; + + > textarea { + display: block; + width: 100%; + max-width: 100%; + min-width: 100%; + padding: 16px; + color: var(--inputText); + background: var(--face); + border: none; + border-bottom: solid var(--lineWidth) var(--faceDivider); + border-radius: 0; + } + + > button { + display: block; + position: absolute; + bottom: 8px; + right: 8px; + margin: 0; + padding: 0 10px; + height: 28px; + color: #fff; + background: var(--accent) !important; + outline: none; + border: none; + border-radius: 4px; + transition: background 0.1s ease; + cursor: pointer; + + &:hover { + background: var(--accentLighten10) !important; + } + + &:active { + background: var(--accentDarken) !important; + transition: background 0s ease; + } + + &:disabled { + opacity: 0.7; + cursor: default; + } + } +} +</style> diff --git a/src/client/widgets/notifications.vue b/src/client/widgets/notifications.vue new file mode 100644 index 0000000000..bc9b3a65a0 --- /dev/null +++ b/src/client/widgets/notifications.vue @@ -0,0 +1,46 @@ +<template> +<div class="mkw-notifications"> + <mk-container :show-header="!props.compact"> + <template #header><fa :icon="faBell"/>{{ $t('notifications') }}</template> + + <div style="height: 300px; overflow: auto; background: var(--bg);"> + <x-notifications/> + </div> + </mk-container> +</div> +</template> + +<script lang="ts"> +import { faBell } from '@fortawesome/free-solid-svg-icons'; +import MkContainer from '../components/ui/container.vue'; +import XNotifications from '../components/notifications.vue'; +import define from './define'; +import i18n from '../i18n'; + +export default define({ + name: 'notifications', + props: () => ({ + compact: false + }) +}).extend({ + i18n, + + components: { + MkContainer, + XNotifications, + }, + + data() { + return { + faBell + }; + }, + + methods: { + func() { + this.props.compact = !this.props.compact; + this.save(); + }, + } +}); +</script> diff --git a/src/client/widgets/rss.vue b/src/client/widgets/rss.vue new file mode 100644 index 0000000000..61c1e23b6e --- /dev/null +++ b/src/client/widgets/rss.vue @@ -0,0 +1,101 @@ +<template> +<div> + <mk-container :show-header="!props.compact"> + <template #header><fa :icon="faRssSquare"/>RSS</template> + <template #func><button class="_button" @click="setting"><fa :icon="faCog"/></button></template> + + <div class="ekmkgxbj"> + <mk-loading v-if="fetching"/> + <div class="feed" v-else> + <a v-for="item in items" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a> + </div> + </div> + </mk-container> +</div> +</template> + +<script lang="ts"> +import { faRssSquare, faCog } from '@fortawesome/free-solid-svg-icons'; +import MkContainer from '../components/ui/container.vue'; +import define from './define'; +import i18n from '../i18n'; + +export default define({ + name: 'rss', + props: () => ({ + compact: false, + url: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews' + }) +}).extend({ + i18n, + components: { + MkContainer + }, + data() { + return { + items: [], + fetching: true, + clock: null, + faRssSquare, faCog + }; + }, + mounted() { + this.fetch(); + this.clock = setInterval(this.fetch, 60000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + func() { + this.props.compact = !this.props.compact; + this.save(); + }, + fetch() { + fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.props.url}`, { + }).then(res => { + res.json().then(feed => { + this.items = feed.items; + this.fetching = false; + }); + }); + }, + setting() { + this.$root.dialog({ + title: 'URL', + input: { + type: 'url', + default: this.props.url + } + }).then(({ canceled, result: url }) => { + if (canceled) return; + this.props.url = url; + this.save(); + this.fetch(); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.ekmkgxbj { + > .feed { + padding: 0; + font-size: 0.9em; + + > a { + display: block; + padding: 8px 16px; + color: var(--text); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + + &:nth-child(even) { + background: rgba(#000, 0.05); + } + } + } +} +</style> diff --git a/src/client/widgets/timeline.vue b/src/client/widgets/timeline.vue new file mode 100644 index 0000000000..5a22a0c1a5 --- /dev/null +++ b/src/client/widgets/timeline.vue @@ -0,0 +1,113 @@ +<template> +<div class="mkw-timeline"> + <mk-container :show-header="!props.compact"> + <template #header> + <button @click="choose" class="_button"> + <fa v-if="props.src === 'home'" :icon="faHome"/> + <fa v-if="props.src === 'local'" :icon="faComments"/> + <fa v-if="props.src === 'social'" :icon="faShareAlt"/> + <fa v-if="props.src === 'global'" :icon="faGlobe"/> + <fa v-if="props.src === 'list'" :icon="faListUl"/> + <fa v-if="props.src === 'antenna'" :icon="faSatellite"/> + <span style="margin-left: 8px;">{{ props.src === 'list' ? props.list.name : props.src === 'antenna' ? props.antenna.name : $t('_timelines.' + props.src) }}</span> + <fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/> + </button> + </template> + + <div style="height: 300px; padding: 8px; overflow: auto; background: var(--bg);"> + <x-timeline :key="props.src === 'list' ? `list:${props.list.id}` : props.src === 'antenna' ? `antenna:${props.antenna.id}` : props.src" :src="props.src" :list="props.list" :antenna="props.antenna"/> + </div> + </mk-container> +</div> +</template> + +<script lang="ts"> +import { faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faListUl, faSatellite } from '@fortawesome/free-solid-svg-icons'; +import { faComments } from '@fortawesome/free-regular-svg-icons'; +import MkContainer from '../components/ui/container.vue'; +import XTimeline from '../components/timeline.vue'; +import define from './define'; +import i18n from '../i18n'; + +export default define({ + name: 'timeline', + props: () => ({ + src: 'home', + list: null, + compact: false + }) +}).extend({ + i18n, + + components: { + MkContainer, + XTimeline, + }, + + data() { + return { + menuOpened: false, + faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faComments, faListUl, faSatellite + }; + }, + + methods: { + func() { + this.props.compact = !this.props.compact; + this.save(); + }, + + async choose(ev) { + this.menuOpened = true; + const [antennas, lists] = await Promise.all([ + this.$root.api('antennas/list'), + this.$root.api('users/lists/list') + ]); + const antennaItems = antennas.map(antenna => ({ + text: antenna.name, + icon: faSatellite, + action: () => { + this.props.antenna = antenna; + this.setSrc('antenna'); + } + })); + const listItems = lists.map(list => ({ + text: list.name, + icon: faListUl, + action: () => { + this.props.list = list; + this.setSrc('list'); + } + })); + this.$root.menu({ + items: [{ + text: this.$t('_timelines.home'), + icon: faHome, + action: () => { this.setSrc('home') } + }, { + text: this.$t('_timelines.local'), + icon: faComments, + action: () => { this.setSrc('local') } + }, { + text: this.$t('_timelines.social'), + icon: faShareAlt, + action: () => { this.setSrc('social') } + }, { + text: this.$t('_timelines.global'), + icon: faGlobe, + action: () => { this.setSrc('global') } + }, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems], + noCenter: true, + source: ev.currentTarget || ev.target + }).then(() => { + this.menuOpened = false; + }); + }, + + setSrc(src) { + this.props.src = src; + this.save(); + }, + } +}); +</script> diff --git a/src/client/app/common/views/components/trends.chart.vue b/src/client/widgets/trends.chart.vue index 5c4f74b6b4..5c4f74b6b4 100644 --- a/src/client/app/common/views/components/trends.chart.vue +++ b/src/client/widgets/trends.chart.vue diff --git a/src/client/widgets/trends.vue b/src/client/widgets/trends.vue new file mode 100644 index 0000000000..7e887f0f22 --- /dev/null +++ b/src/client/widgets/trends.vue @@ -0,0 +1,124 @@ +<template> +<div> + <mk-container :show-header="!props.compact"> + <template #header><fa :icon="faHashtag"/>{{ $t('_widgets.trends') }}</template> + + <div class="wbrkwala"> + <transition-group tag="div" name="chart"> + <div v-for="stat in stats" :key="stat.tag"> + <div class="tag"> + <router-link :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</router-link> + <p>{{ $t('count').replace('{}', stat.usersCount) }}</p> + </div> + <x-chart class="chart" :src="stat.chart"/> + </div> + </transition-group> + </div> + </mk-container> +</div> +</template> + +<script lang="ts"> +import { faHashtag } from '@fortawesome/free-solid-svg-icons'; +import MkContainer from '../components/ui/container.vue'; +import define from './define'; +import i18n from '../i18n'; +import XChart from './trends.chart.vue'; + +export default define({ + name: 'hashtags', + props: () => ({ + compact: false + }) +}).extend({ + i18n, + components: { + MkContainer, XChart + }, + data() { + return { + stats: [], + fetching: true, + faHashtag + }; + }, + mounted() { + this.fetch(); + this.clock = setInterval(this.fetch, 1000 * 60); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + func() { + this.props.compact = !this.props.compact; + this.save(); + }, + fetch() { + this.$root.api('hashtags/trend').then(stats => { + this.stats = stats; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.wbrkwala { + > .fetching, + > .empty { + margin: 0; + padding: 16px; + text-align: center; + color: var(--text); + opacity: 0.7; + + > [data-icon] { + margin-right: 4px; + } + } + + > div { + .chart-move { + transition: transform 1s ease; + } + + > div { + display: flex; + align-items: center; + padding: 14px 16px; + + &:not(:last-child) { + border-bottom: solid 1px var(--divider); + } + + > .tag { + flex: 1; + overflow: hidden; + font-size: 14px; + color: var(--fg); + + > a { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: inherit; + } + + > p { + margin: 0; + font-size: 75%; + opacity: 0.7; + } + } + + > .chart { + height: 30px; + } + } + } +} +</style> |