diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2020-10-17 20:12:00 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-10-17 20:12:00 +0900 |
| commit | 7199e6f4e0b3a2c2bc198e689c3e0cd0d0f0354a (patch) | |
| tree | 2263a06acec7fa21882366bae26d1a983ce21135 /src | |
| parent | CW の input でも投稿ショートカットが動作するように (#6690) (diff) | |
| download | sharkey-7199e6f4e0b3a2c2bc198e689c3e0cd0d0f0354a.tar.gz sharkey-7199e6f4e0b3a2c2bc198e689c3e0cd0d0f0354a.tar.bz2 sharkey-7199e6f4e0b3a2c2bc198e689c3e0cd0d0f0354a.zip | |
Migrate to Vue3 (#6587)
* Update reaction.vue
* fix bug
* wip
* wip
* wjio
* wip
* Revert "wip"
This reverts commit e427f2160adf4e8a4147006e25a89854edab0033.
* wip
* wip
* wip
* Update init.ts
* Update drive-window.vue
* wip
* wip
* Use PascalCase for components
* Use PascalCase for components
* update dep
* wip
* wip
* wip
* Update init.ts
* wip
* Update paging.ts
* Update test.vue
* watch deep
* wip
* lint
* wip
* wip
* wip
* wip
* wiop
* wip
* Update webpack.config.ts
* alllow null poll
* wip
* wip
* wip
* wiop
* UI redesign & refactor (#6714)
* wip
* wip
* wip
* wip
* wip
* Update drive.vue
* Update word-mute.vue
* wip
* wip
* wip
* clean up
* wip
* Update default.vue
* wip
* Update notes.vue
* Update mfm.ts
* Update index.home.vue
* Update post-form.vue
* Update post-form-attaches.vue
* wip
* Update post-form.vue
* Update sidebar.vue
* wip
* wip
* Update index.vue
* wip
* Update default.vue
* Update index.vue
* Update index.vue
* wip
* Update post-form-attaches.vue
* Update note.vue
* wip
* clean up
* Update notes.vue
* wip
* wip
* Update ja-JP.yml
* wip
* wip
* Update index.vue
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update default.vue
* wip
* Update _dark.json5
* wip
* wip
* wip
* clean up
* wip
* wip
* Update index.vue
* Update test.vue
* wip
* wip
* fix
* wip
* wip
* wip
* wip
* clena yop
* wip
* wip
* Update store.ts
* Update messaging-room.vue
* Update default.widgets.vue
* fix
* wip
* wip
* Update modal.vue
* wip
* Update os.ts
* Update os.ts
* Update deck.vue
* Update init.ts
* wip
* Update ja-JP.yml
* v-sizeは単にwindowのresizeを監視するだけで良いかもしれない
* Update modal.vue
* wip
* Update tooltip.ts
* wip
* wip
* wip
* wip
* wip
* Update image-viewer.vue
* wip
* wip
* Update style.scss
* Update style.scss
* Update visitor.vue
* wip
* Update init.ts
* Update init.ts
* wip
* wip
* Update visitor.vue
* Update visitor.vue
* Update visitor.vue
* Update visitor.vue
* wip
* wip
* Update modal.vue
* Update header.vue
* Update menu.vue
* Update about.vue
* Update about-misskey.vue
* wip
* wip
* Update visitor.vue
* Update tooltip.ts
* wip
* Update drive.vue
* wip
* Update style.scss
* Update header.vue
* wip
* wip
* Update users.user.vue
* Update announcements.vue
* wip
* wip
* wip
* Update emojis.vue
* wip
* Update emojis.vue
* Update style.scss
* Update users.vue
* wip
* Update style.scss
* wip
* Update welcome.entrance.vue
* Update radio.vue
* Update size.ts
* Update emoji-edit-dialog.vue
* wip
* Update emojis.vue
* wip
* Update emojis.vue
* Update emojis.vue
* Update emojis.vue
* wip
* wip
* wip
* wip
* Update file-dialog.vue
* wip
* wip
* Update token-generate-window.vue
* Update notification-setting-window.vue
* wip
* wip
* Update _error_.vue
* Update ja-JP.yml
* wip
* wip
* Update store.ts
* Update emojis.vue
* Update emojis.vue
* Update emojis.vue
* Update announcements.vue
* Update store.ts
* wip
* Update page-editor.vue
* wip
* wip
* Update modal.vue
* wip
* Update select-file.ts
* Update timeline.vue
* Update emojis.vue
* Update os.ts
* wip
* Update user-select.vue
* Update mfm.ts
* Update get-file-info.ts
* Update drive.vue
* Update init.ts
* Update mfm.ts
* wip
* wip
* Update window.vue
* Update note.vue
* wip
* wip
* Update user-info.vue
* wip
* wip
* wip
* wip
* wip
* Update header.vue
* Update header.vue
* wip
* Update explore.vue
* wip
* wip
* wip
* Update webpack.config.ts
* wip
* wip
* wip
* wip
* wip
* wip
* Update autocomplete.ts
* wip
* wip
* wip
* Update toast.vue
* wip
* Update post-form-dialog.vue
* wip
* wip
* wip
* wip
* wip
* Update users.vue
* wip
* Update explore.vue
* wip
* wip
* wip
* Update package.json
* wip
* Update icon-dialog.vue
* wip
* wip
* Update user-preview.ts
* wip
* wip
* wip
* wip
* wip
* Update instance.vue
* Update user-name.vue
* Update federation.vue
* Update instance.vue
* wip
* wip
* Update tag.vue
* wip
* wip
* wip
* wip
* wip
* Update instance.vue
* wip
* Update os.ts
* Update os.ts
* wip
* wip
* wip
* Update router.ts
* wip
* Update init.ts
* Update note.vue
* Update messages.vue
* wip
* wip
* wip
* wip
* wip
* google
* wip
* wip
* wip
* wip
* Update theme-editor.vue
* wip
* wip
* Update room.vue
* Update channel-editor.vue
* wip
* Update window.vue
* Update window.vue
* wip
* Update window.vue
* Update window.vue
* wip
* Update menu.vue
* wip
* wip
* wip
* wip
* Update messaging-room.vue
* wip
* Update post-form.vue
* Update default.widgets.vue
* Update window.vue
* wip
Diffstat (limited to 'src')
352 files changed, 13795 insertions, 11554 deletions
diff --git a/src/client/.eslintrc b/src/client/.eslintrc new file mode 100644 index 0000000000..8829472b49 --- /dev/null +++ b/src/client/.eslintrc @@ -0,0 +1,12 @@ +{ + "globals": { + "_DEV_": false, + "_LANGS_": false, + "_VERSION_": false, + "_ENV_": false, + "_PERF_PREFIX_": false, + "_DATA_TRANSFER_DRIVE_FILE_": false, + "_DATA_TRANSFER_DRIVE_FOLDER_": false, + "_DATA_TRANSFER_DECK_COLUMN_": false + } +} diff --git a/src/client/@types/global.d.ts b/src/client/@types/global.d.ts new file mode 100644 index 0000000000..670774fdf4 --- /dev/null +++ b/src/client/@types/global.d.ts @@ -0,0 +1,8 @@ +declare const _LANGS_: string[]; +declare const _VERSION_: string; +declare const _ENV_: string; +declare const _DEV_: boolean; +declare const _PERF_PREFIX_: string; +declare const _DATA_TRANSFER_DRIVE_FILE_: string; +declare const _DATA_TRANSFER_DRIVE_FOLDER_: string; +declare const _DATA_TRANSFER_DECK_COLUMN_: string; diff --git a/src/client/v.d.ts b/src/client/@types/vue.d.ts index b3a21c6cdb..b3a21c6cdb 100644 --- a/src/client/v.d.ts +++ b/src/client/@types/vue.d.ts diff --git a/src/client/@types/vuex-shim.d.ts b/src/client/@types/vuex-shim.d.ts new file mode 100644 index 0000000000..b15424d792 --- /dev/null +++ b/src/client/@types/vuex-shim.d.ts @@ -0,0 +1,11 @@ +import { ComponentCustomProperties } from 'vue'; +import { Store } from 'vuex'; + +declare module '@vue/runtime-core' { + interface State { + } + + interface ComponentCustomProperties { + $store: Store<State> + } +} diff --git a/src/client/app.vue b/src/client/app.vue deleted file mode 100644 index 3453baa280..0000000000 --- a/src/client/app.vue +++ /dev/null @@ -1,788 +0,0 @@ -<template> -<div class="mk-app" v-hotkey.global="keymap"> - <header class="header" ref="header"> - <div class="title" ref="title"> - <transition :name="$store.state.device.animation ? 'header' : ''" mode="out-in" appear> - <button class="_button back" v-if="canBack" @click="back()"><fa :icon="faChevronLeft"/></button> - </transition> - <transition :name="$store.state.device.animation ? '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"> - <template v-if="$store.getters.isSignedIn"> - <button v-if="widgetsEditMode" class="_button edit active" @click="widgetsEditMode = false"><fa :icon="faGripVertical"/></button> - <button v-else class="_button edit" @click="widgetsEditMode = true"><fa :icon="faGripVertical"/></button> - </template> - <div class="search"> - <fa :icon="faSearch"/> - <input type="search" :placeholder="$t('search')" v-model="searchQuery" v-autocomplete="{ model: 'searchQuery' }" :disabled="searchWait" @keypress="searchKeypress"/> - </div> - <button v-if="$store.getters.isSignedIn" class="post _buttonPrimary" @click="post()"><fa :icon="faPencilAlt"/></button> - <x-clock v-if="isDesktop" class="clock"/> - </div> - </header> - - <x-sidebar ref="nav" @change-view-mode="calcHeaderWidth"/> - - <div class="contents" ref="contents" :class="{ wallpaper, full: $store.state.fullView }"> - <main ref="main"> - <div class="content"> - <transition :name="$store.state.device.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> - <keep-alive :include="['index']"> - <router-view></router-view> - </keep-alive> - </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> - - <template v-if="isDesktop"> - <div v-for="place in ['left', 'right']" ref="widgets" class="widgets" :class="{ edit: widgetsEditMode, fixed: $store.state.device.fixedWidgetsPosition, empty: widgets[place].length === 0 && !widgetsEditMode }" :key="place"> - <div class="spacer"></div> - <div class="container" v-if="widgetsEditMode"> - <mk-button primary @click="addWidget(place)" class="add"><fa :icon="faPlus"/></mk-button> - <x-draggable - :list="widgets[place]" - handle=".handle" - animation="150" - class="sortable" - @sort="onWidgetSort" - > - <div v-for="widget in widgets[place]" class="customize-container _panel" :key="widget.id"> - <header> - <span class="handle"><fa :icon="faBars"/></span>{{ $t('_widgets.' + widget.name) }}<button class="remove _button" @click="removeWidget(widget)"><fa :icon="faTimes"/></button> - </header> - <div @click="widgetFunc(widget.id)"> - <component class="_close_ _forceContainerFull_" :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true"/> - </div> - </div> - </x-draggable> - </div> - <div class="container" v-else> - <component v-for="widget in widgets[place]" class="_close_ _forceContainerFull_" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget"/> - </div> - </div> - </template> - </div> - - <div class="buttons" :class="{ navHidden }"> - <button class="button nav _button" @click="showNav" ref="navButton"><fa :icon="faBars"/><i v-if="navIndicated"><fa :icon="faCircle"/></i></button> - <button v-if="$route.name === 'index'" class="button home _button" @click="top()"><fa :icon="faHome"/></button> - <button v-else class="button home _button" @click="$router.push('/')"><fa :icon="faHome"/></button> - <button v-if="$store.getters.isSignedIn" class="button notifications _button" @click="$router.push('/my/notifications')"><fa :icon="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" :class="{ navHidden }" @click="post()"><fa :icon="faPencilAlt"/></button> - - <stream-indicator v-if="$store.getters.isSignedIn"/> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faGripVertical, 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, faServer, faInfoCircle, faQuestionCircle, faProjectDiagram } from '@fortawesome/free-solid-svg-icons'; -import { faBell, faEnvelope, faLaugh, faComments } from '@fortawesome/free-regular-svg-icons'; -import { v4 as uuid } from 'uuid'; -import { host } from './config'; -import { search } from './scripts/search'; -import { StickySidebar } from './scripts/sticky-sidebar'; -import { widgets } from './widgets'; -import XSidebar from './components/sidebar.vue'; - -const DESKTOP_THRESHOLD = 1100; - -export default Vue.extend({ - components: { - XSidebar, - XClock: () => import('./components/header-clock.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, - connection: null, - searchQuery: '', - searchWait: false, - widgetsEditMode: false, - isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, - canBack: false, - menuDef: this.$store.getters.nav({}), - navHidden: false, - wallpaper: localStorage.getItem('wallpaper') != null, - faGripVertical, 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, faProjectDiagram - }; - }, - - computed: { - keymap(): any { - return { - 'd': () => { - if (this.$store.state.device.syncDeviceDarkMode) return; - this.$store.commit('device/set', { key: 'darkMode', value: !this.$store.state.device.darkMode }); - }, - 'p': this.post, - 'n': this.post, - 's': this.search, - 'h|/': this.help - }; - }, - - widgets(): any { - if (this.$store.getters.isSignedIn) { - const widgets = this.$store.state.deviceUser.widgets; - return { - left: widgets.filter(x => x.place === 'left'), - right: widgets.filter(x => x.place == null || x.place === 'right'), - mobile: widgets.filter(x => x.place === 'mobile'), - }; - } else { - const right = [{ - name: 'calendar', - id: 'b', place: 'right', data: {} - }, { - name: 'trends', - id: 'c', place: 'right', data: {} - }]; - - if (this.$route.name !== 'index') { - right.unshift({ - name: 'welcome', - id: 'a', place: 'right', data: {} - }); - } - - return { - left: [], - right, - mobile: [], - }; - } - }, - - menu(): string[] { - return this.$store.state.deviceUser.menu; - }, - - navIndicated(): boolean { - if (!this.$store.getters.isSignedIn) return false; - for (const def in this.menuDef) { - if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから - if (this.menuDef[def].indicated) return true; - } - return false; - } - }, - - watch: { - $route(to, from) { - this.pageKey++; - this.canBack = (window.history.length > 0 && !['index'].includes(to.name)); - }, - - isDesktop() { - this.$nextTick(() => { - this.attachSticky(); - }); - } - }, - - created() { - document.documentElement.style.overflowY = 'scroll'; - - if (this.$store.getters.isSignedIn) { - this.connection = this.$root.stream.useSharedConnection('main'); - this.connection.on('notification', this.onNotification); - - if (this.$store.state.deviceUser.widgets.length === 0) { - this.$store.commit('deviceUser/setWidgets', [{ - name: 'calendar', - id: 'a', place: 'right', data: {} - }, { - name: 'notifications', - id: 'b', place: 'right', data: {} - }, { - name: 'trends', - id: 'c', place: 'right', data: {} - }]); - } - } - }, - - mounted() { - this.adjustTitlePosition(); - - const ro = new ResizeObserver((entries, observer) => { - this.adjustTitlePosition(); - }); - - ro.observe(this.$refs.contents); - - window.addEventListener('resize', this.adjustTitlePosition, { passive: true }); - - if (!this.isDesktop) { - window.addEventListener('resize', () => { - if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true; - }, { passive: true }); - } - - // widget follow - this.attachSticky(); - - this.$nextTick(() => { - this.calcHeaderWidth(); - }); - }, - - methods: { - adjustTitlePosition() { - const left = this.$refs.main.getBoundingClientRect().left - this.$refs.nav.$el.offsetWidth; - if (left >= 0) { - this.$refs.title.style.left = left + 'px'; - } - }, - - calcHeaderWidth() { - const navWidth = this.$refs.nav.$el.offsetWidth; - this.navHidden = navWidth === 0; - this.$refs.header.style.width = `calc(100% - ${navWidth}px)`; - this.adjustTitlePosition(); - }, - - showNav() { - this.$refs.nav.show(); - }, - - attachSticky() { - if (!this.isDesktop) return; - if (this.$store.state.device.fixedWidgetsPosition) return; - - const stickyWidgetColumns = this.$refs.widgets.map(w => new StickySidebar(w.children[1], w.children[0], w.offsetTop)); - window.addEventListener('scroll', () => { - for (const stickyWidgetColumn of stickyWidgetColumns) { - stickyWidgetColumn.calc(window.scrollY); - } - }, { passive: true }); - }, - - top() { - window.scroll({ top: 0, behavior: 'smooth' }); - }, - - help() { - this.$router.push('/docs/keyboard-shortcut'); - }, - - back() { - if (this.canBack) window.history.back(); - }, - - onTransition() { - if (window._scroll) window._scroll(); - }, - - 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 = ''; - }); - } - }, - - async onNotification(notification) { - if (this.$store.state.i.mutingNotificationTypes.includes(notification.type)) { - return; - } - if (document.visibilityState === 'visible') { - this.$root.stream.send('readNotification', { - id: notification.id - }); - - this.$root.new(await import('./components/toast.vue').then(m => m.default), { - notification - }); - } - - this.$root.sound('notification'); - }, - - widgetFunc(id) { - this.$refs[id][0].setting(); - }, - - onWidgetSort() { - this.saveHome(); - }, - - async addWidget(place) { - const { canceled, result: widget } = await this.$root.dialog({ - type: null, - title: this.$t('chooseWidget'), - select: { - items: widgets.map(widget => ({ - value: widget, - text: this.$t('_widgets.' + widget), - })) - }, - showCancelButton: true - }); - if (canceled) return; - - this.$store.commit('deviceUser/addWidget', { - name: widget, - id: uuid(), - place: place, - data: {} - }); - }, - - removeWidget(widget) { - this.$store.commit('deviceUser/removeWidget', widget); - }, - - saveHome() { - this.$store.commit('deviceUser/setWidgets', [...this.widgets.left, ...this.widgets.right, ...this.widgets.mobile]); - } - } -}); -</script> - -<style lang="scss" scoped> -.mk-app { - $header-height: 60px; - $main-width: 670px; - $ui-font-size: 1em; // TODO: どこかに集約したい - $header-sub-hide-threshold: 1090px; - $left-widgets-hide-threshold: 1600px; - $right-widgets-hide-threshold: 1090px; - - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - min-height: calc(var(--vh, 1vh) * 100); - box-sizing: border-box; - 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: 100%; - //background-color: var(--panel); - -webkit-backdrop-filter: blur(32px); - backdrop-filter: blur(32px); - background-color: var(--header); - border-bottom: solid 1px var(--divider); - - > .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); - display: flex; - align-items: center; - position: absolute; - top: 0; - right: 16px; - height: $header-height; - - @media (max-width: $header-sub-hide-threshold) { - display: none; - } - - > .edit { - padding: 16px; - - &.active { - color: var(--accent); - } - } - - > .search { - position: relative; - - > input { - width: 220px; - box-sizing: border-box; - margin-right: 8px; - padding: 0 12px 0 42px; - font-size: 1rem; - line-height: 38px; - border: none; - border-radius: 38px; - color: var(--fg); - background: var(--bg); - -webkit-appearance: textfield; - - &:focus { - outline: none; - } - } - - > [data-icon] { - position: absolute; - top: 0; - left: 16px; - height: 100%; - pointer-events: none; - font-size: 16px; - } - } - - > .post { - width: $post-button-size; - height: $post-button-size; - margin-left: $post-button-margin; - border-radius: 100%; - font-size: 16px; - } - - > .clock { - margin-left: 8px; - } - } - } - - > .contents { - display: flex; - margin: 0 auto; - min-width: 0; - - &.wallpaper { - background: var(--wallpaperOverlay); - backdrop-filter: blur(4px); - } - - &.full { - width: 100%; - - > main { - width: 100%; - } - - > .widgets { - display: none; - } - } - - > main { - width: $main-width; - min-width: 0; - - > .content { - > * { - // ほんとは単に calc(100vh - #{$header-height}) と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - min-height: calc((var(--vh, 1vh) * 100) - #{$header-height}); - box-sizing: border-box; - padding: var(--margin); - - &.full { - padding: 0 var(--margin); - } - } - } - - > .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 { - padding: 0 var(--margin); - box-shadow: 1px 0 0 0 var(--divider), -1px 0 0 0 var(--divider); - - &.fixed { - position: sticky; - overflow: auto; - // ほんとは単に calc(100vh - #{$header-height}) と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - height: calc((var(--vh, 1vh) * 100) - #{$header-height}); - top: $header-height; - } - - &:first-of-type { - order: -1; - - @media (max-width: $left-widgets-hide-threshold) { - display: none; - } - } - - &.empty { - display: none; - } - - @media (max-width: $right-widgets-hide-threshold) { - display: none; - } - - > .container { - position: sticky; - height: min-content; - // ほんとは単に calc(100vh - #{$header-height}) と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - min-height: calc((var(--vh, 1vh) * 100) - #{$header-height}); - padding: var(--margin) 0; - box-sizing: border-box; - - > * { - margin: var(--margin) 0; - width: 300px; - - &:first-child { - margin-top: 0; - } - - &:last-child { - margin-bottom: 0; - } - } - } - - > .add { - margin: 0 auto; - } - - .customize-container { - margin: 8px 0; - - > header { - position: relative; - line-height: 32px; - - > .handle { - padding: 0 8px; - cursor: move; - } - - > .remove { - position: absolute; - top: 0; - right: 0; - padding: 0 8px; - line-height: 32px; - } - } - - > div { - padding: 8px; - - > * { - pointer-events: none; - } - } - } - } - } - - > .post { - display: block; - position: fixed; - z-index: 1000; - bottom: 32px; - right: 32px; - width: 64px; - height: 64px; - border-radius: 100%; - box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12); - font-size: 22px; - - &.navHidden { - display: none; - } - - @media (min-width: ($header-sub-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(--X1)); - - @media (max-width: 500px) { - padding: 0 16px 16px 16px; - } - - &:not(.navHidden) { - 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(--X2); - } - - > i { - position: absolute; - top: 0; - left: 0; - color: var(--indicator); - font-size: 16px; - animation: blink 1s infinite; - } - } - } - } -} -</style> diff --git a/src/client/components/acct.vue b/src/client/components/acct.vue index 250e8b2371..9d434de6cd 100644 --- a/src/client/components/acct.vue +++ b/src/client/components/acct.vue @@ -6,11 +6,11 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { toUnicode } from 'punycode'; -import { host } from '../config'; +import { host } from '@/config'; -export default Vue.extend({ +export default defineComponent({ props: ['user', 'detail'], data() { return { diff --git a/src/client/components/analog-clock.vue b/src/client/components/analog-clock.vue index 81c6598c7a..b3fb7a515d 100644 --- a/src/client/components/analog-clock.vue +++ b/src/client/components/analog-clock.vue @@ -34,10 +34,11 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import * as tinycolor from 'tinycolor2'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ data() { return { now: new Date(), @@ -127,7 +128,7 @@ export default Vue.extend({ }); }, - beforeDestroy() { + beforeUnmount() { this.enabled = false; }, diff --git a/src/client/components/autocomplete.vue b/src/client/components/autocomplete.vue index e77b0d1a99..7f4b565723 100644 --- a/src/client/components/autocomplete.vue +++ b/src/client/components/autocomplete.vue @@ -1,12 +1,12 @@ <template> -<div class="swhvrteh" @contextmenu.prevent="() => {}"> +<div class="swhvrteh _popup _shadow" @contextmenu.prevent="() => {}"> <ol class="users" ref="suggests" v-if="type === 'user'"> <li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1" class="user"> <img class="avatar" :src="user.avatarUrl"/> <span class="name"> - <mk-user-name :user="user" :key="user.id"/> + <MkUserName :user="user" :key="user.id"/> </span> - <span class="username">@{{ user | acct }}</span> + <span class="username">@{{ acct(user) }}</span> </li> <li @click="chooseUser()" @keydown="onKeydown" tabindex="-1" class="choose">{{ $t('selectUser') }}</li> </ol> @@ -28,12 +28,13 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { emojilist } from '../../misc/emojilist'; -import contains from '../scripts/contains'; +import contains from '@/scripts/contains'; import { twemojiSvgBase } from '../../misc/twemoji-base'; -import { getStaticImageUrl } from '../scripts/get-static-image-url'; -import MkUserSelect from './user-select.vue'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import { acct } from '@/filters/user'; +import * as os from '@/os'; type EmojiDef = { emoji: string; @@ -74,7 +75,7 @@ for (const x of lib) { emjdb.sort((a, b) => a.name.length - b.name.length); -export default Vue.extend({ +export default defineComponent({ props: { type: { type: String, @@ -91,11 +92,6 @@ export default Vue.extend({ required: true, }, - complete: { - type: Function, - required: true, - }, - close: { type: Function, required: true, @@ -110,8 +106,15 @@ export default Vue.extend({ type: Number, required: true, }, + + showing: { + type: Boolean, + required: true + }, }, + emits: ['done', 'closed'], + data() { return { getStaticImageUrl, @@ -135,6 +138,14 @@ export default Vue.extend({ } }, + watch: { + showing() { + if (!this.showing) { + this.$emit('closed'); + } + } + }, + updated() { this.setPosition(); }, @@ -189,7 +200,7 @@ export default Vue.extend({ }); }, - beforeDestroy() { + beforeUnmount() { this.textarea.removeEventListener('keydown', this.onKeydown); for (const el of Array.from(document.querySelectorAll('body *'))) { @@ -198,6 +209,11 @@ export default Vue.extend({ }, methods: { + complete(type, value) { + this.$emit('done', { type, value }); + this.$emit('closed'); + }, + setPosition() { if (this.x + this.$el.offsetWidth > window.innerWidth) { this.$el.style.left = (window.innerWidth - this.$el.offsetWidth) + 'px'; @@ -236,8 +252,8 @@ export default Vue.extend({ this.users = users; this.fetching = false; } else { - this.$root.api('users/search', { - query: this.q, + os.api('users/search-by-username-and-host', { + username: this.q, limit: 10, detail: false }).then(users => { @@ -260,7 +276,7 @@ export default Vue.extend({ this.hashtags = hashtags; this.fetching = false; } else { - this.$root.api('hashtags/search', { + os.api('hashtags/search', { query: this.q, limit: 30 }).then(hashtags => { @@ -374,14 +390,13 @@ export default Vue.extend({ chooseUser() { this.close(); - const vm = this.$root.new(MkUserSelect, {}); - vm.$once('selected', user => { + os.selectUser().then(user => { this.complete('user', user); - }); - vm.$once('closed', () => { this.textarea.focus(); }); - } + }, + + acct } }); </script> @@ -393,9 +408,6 @@ export default Vue.extend({ 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 { diff --git a/src/client/components/avatar.vue b/src/client/components/avatar.vue index ec48d73214..627818a8e7 100644 --- a/src/client/components/avatar.vue +++ b/src/client/components/avatar.vue @@ -1,17 +1,19 @@ <template> -<span class="eiwwqkts" :class="{ cat }" :title="user | acct" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick"> +<span class="eiwwqkts" :class="{ cat }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick"> <img class="inner" :src="url"/> </span> -<router-link class="eiwwqkts" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id"> +<router-link class="eiwwqkts" :class="{ cat }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id"> <img class="inner" :src="url"/> </router-link> </template> <script lang="ts"> -import Vue from 'vue'; -import { getStaticImageUrl } from '../scripts/get-static-image-url'; +import { defineComponent } from 'vue'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash'; +import { acct, userPage } from '../filters/user'; -export default Vue.extend({ +export default defineComponent({ props: { user: { type: Object, @@ -30,6 +32,7 @@ export default Vue.extend({ default: false } }, + emits: ['click'], computed: { cat(): boolean { return this.user.isCat; @@ -42,25 +45,19 @@ export default Vue.extend({ }, watch: { 'user.avatarBlurhash'() { - this.$el.style.color = this.getBlurhashAvgColor(this.user.avatarBlurhash); + if (this.$el == null) return; + this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash); } }, mounted() { - this.$el.style.color = this.getBlurhashAvgColor(this.user.avatarBlurhash); + this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash); }, methods: { - getBlurhashAvgColor(s) { - return typeof s == 'string' - ? '#' + [...s.slice(2, 6)] - .map(x => '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'.indexOf(x)) - .reduce((a, c) => a * 83 + c, 0) - .toString(16) - .padStart(6, '0') - : undefined; - }, onClick(e) { this.$emit('click', e); - } + }, + acct, + userPage } }); </script> @@ -95,7 +92,7 @@ export default Vue.extend({ transform: rotate(-37.5deg) skew(-30deg); } } - + .inner { position: absolute; bottom: 0; diff --git a/src/client/components/avatars.vue b/src/client/components/avatars.vue index db618dc7bf..8bf64d79b5 100644 --- a/src/client/components/avatars.vue +++ b/src/client/components/avatars.vue @@ -1,15 +1,16 @@ <template> <div> <div v-for="user in us" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;"> - <mk-avatar :user="user" style="width:32px;height:32px;"/> + <MkAvatar :user="user" style="width:32px;height:32px;"/> </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { userIds: { required: true @@ -21,7 +22,7 @@ export default Vue.extend({ }; }, async created() { - this.us = await this.$root.api('users/show', { + this.us = await os.api('users/show', { userIds: this.userIds }); } diff --git a/src/client/components/captcha.vue b/src/client/components/captcha.vue index 1a894d9350..94a13a8b5b 100644 --- a/src/client/components/captcha.vue +++ b/src/client/components/captcha.vue @@ -1,12 +1,12 @@ <template> <div> - <span v-if="!available">{{ $t('waiting') }}<mk-ellipsis/></span> + <span v-if="!available">{{ $t('waiting') }}<MkEllipsis/></span> <div ref="captcha"></div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; type Captcha = { render(container: string | Node, options: { @@ -28,8 +28,9 @@ declare global { interface Window extends CaptchaContainer { } } +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { provider: { type: String, @@ -88,7 +89,7 @@ export default Vue.extend({ } }, - beforeDestroy() { + beforeUnmount() { this.reset(); }, @@ -110,7 +111,7 @@ export default Vue.extend({ } }, callback(response?: string) { - this.$emit('input', typeof response == 'string' ? response : null); + this.$emit('update:value', typeof response == 'string' ? response : null); }, }, }); diff --git a/src/client/components/channel-follow-button.vue b/src/client/components/channel-follow-button.vue index 3b83865b55..c59c319fd2 100644 --- a/src/client/components/channel-follow-button.vue +++ b/src/client/components/channel-follow-button.vue @@ -6,23 +6,24 @@ > <template v-if="!wait"> <template v-if="isFollowing"> - <span v-if="full">{{ $t('unfollow') }}</span><fa :icon="faMinus"/> + <span v-if="full">{{ $t('unfollow') }}</span><Fa :icon="faMinus"/> </template> <template v-else> - <span v-if="full">{{ $t('follow') }}</span><fa :icon="faPlus"/> + <span v-if="full">{{ $t('follow') }}</span><Fa :icon="faPlus"/> </template> </template> <template v-else> - <span v-if="full">{{ $t('processing') }}</span><fa :icon="faSpinner" pulse fixed-width/> + <span v-if="full">{{ $t('processing') }}</span><Fa :icon="faSpinner" pulse fixed-width/> </template> </button> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faSpinner, faPlus, faMinus, } from '@fortawesome/free-solid-svg-icons'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { channel: { type: Object, @@ -49,12 +50,12 @@ export default Vue.extend({ try { if (this.isFollowing) { - await this.$root.api('channels/unfollow', { + await os.api('channels/unfollow', { channelId: this.channel.id }); this.isFollowing = false; } else { - await this.$root.api('channels/follow', { + await os.api('channels/follow', { channelId: this.channel.id }); this.isFollowing = true; diff --git a/src/client/components/channel-preview.vue b/src/client/components/channel-preview.vue index bef4759570..705d3b09c4 100644 --- a/src/client/components/channel-preview.vue +++ b/src/client/components/channel-preview.vue @@ -2,28 +2,42 @@ <router-link :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1"> <div class="banner" v-if="channel.bannerUrl" :style="`background-image: url('${channel.bannerUrl}')`"> <div class="fade"></div> - <div class="name"><fa :icon="faSatelliteDish"/> {{ channel.name }}</div> + <div class="name"><Fa :icon="faSatelliteDish"/> {{ channel.name }}</div> <div class="status"> - <div><fa :icon="faUsers" fixed-width/><i18n path="_channel.usersCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.usersCount }}</b></i18n></div> - <div><fa :icon="faPencilAlt" fixed-width/><i18n path="_channel.notesCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.notesCount }}</b></i18n></div> + <div> + <Fa :icon="faUsers" fixed-width/> + <i18n-t keypath="_channel.usersCount" tag="span" style="margin-left: 4px;"> + <template #n> + <b>{{ channel.usersCount }}</b> + </template> + </i18n-t> + </div> + <div> + <Fa :icon="faPencilAlt" fixed-width/> + <i18n-t keypath="_channel.notesCount" tag="span" style="margin-left: 4px;"> + <template #n> + <b>{{ channel.notesCount }}</b> + </template> + </i18n-t> + </div> </div> </div> <article v-if="channel.description"> <p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p> </article> <footer> - <span> - {{ $t('updatedAt') }}: <mk-time :time="channel.lastNotedAt"/> + <span v-if="channel.lastNotedAt"> + {{ $t('updatedAt') }}: <MkTime :time="channel.lastNotedAt"/> </span> </footer> </router-link> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faSatelliteDish, faUsers, faPencilAlt } from '@fortawesome/free-solid-svg-icons'; -export default Vue.extend({ +export default defineComponent({ props: { channel: { type: Object, @@ -44,7 +58,6 @@ export default Vue.extend({ display: block; overflow: hidden; width: 100%; - border: 1px solid var(--divider); &:hover { text-decoration: none; diff --git a/src/client/components/code-core.vue b/src/client/components/code-core.vue index a9253528d9..cfb9d47ed9 100644 --- a/src/client/components/code-core.vue +++ b/src/client/components/code-core.vue @@ -1,13 +1,14 @@ <template> -<x-prism :inline="inline" :language="prismLang">{{ code }}</x-prism> +<XPrism :inline="inline" :language="prismLang">{{ code }}</XPrism> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import 'prismjs'; import 'prismjs/themes/prism-okaidia.css'; -import XPrism from 'vue-prism-component'; -export default Vue.extend({ +import XPrism from 'vue-prism-component';import * as os from '@/os'; + +export default defineComponent({ components: { XPrism }, diff --git a/src/client/components/code.vue b/src/client/components/code.vue index 94cad57be4..f5d6c5673a 100644 --- a/src/client/components/code.vue +++ b/src/client/components/code.vue @@ -1,12 +1,13 @@ <template> -<x-code :code="code" :lang="lang" :inline="inline"/> +<XCode :code="code" :lang="lang" :inline="inline"/> </template> <script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ +import { defineComponent, defineAsyncComponent } from 'vue'; + +export default defineComponent({ components: { - XCode: () => import('./code-core.vue').then(m => m.default) + XCode: defineAsyncComponent(() => import('./code-core.vue')) }, props: { code: { diff --git a/src/client/components/cw-button.vue b/src/client/components/cw-button.vue index 16a9b84f62..d052c410d1 100644 --- a/src/client/components/cw-button.vue +++ b/src/client/components/cw-button.vue @@ -1,16 +1,16 @@ <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 class="nrvgflfu _button" @click="toggle"> + <b>{{ value ? $t('_cw.hide') : $t('_cw.show') }}</b> + <span v-if="!value">{{ label }}</span> </button> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { length } from 'stringz'; import { concat } from '../../prelude/array'; -export default Vue.extend({ +export default defineComponent({ props: { value: { type: Boolean, @@ -36,14 +36,14 @@ export default Vue.extend({ length, toggle() { - this.$emit('input', !this.value); + this.$emit('update:value', !this.value); } } }); </script> <style lang="scss" scoped> -.nrvgflfuaxwgkxoynpnumyookecqrrvh { +.nrvgflfu { display: inline-block; padding: 4px 8px; font-size: 0.7em; diff --git a/src/client/components/date-separated-list.vue b/src/client/components/date-separated-list.vue index 6d42429166..ef96297465 100644 --- a/src/client/components/date-separated-list.vue +++ b/src/client/components/date-separated-list.vue @@ -1,22 +1,22 @@ <template> -<component :is="$store.state.device.animation ? 'transition-group' : 'div'" class="sqadhkmv _list_" name="list" tag="div" :data-direction="direction" :data-reversed="reversed ? 'true' : 'false'"> +<transition-group class="sqadhkmv _list_" name="list" tag="div" :data-direction="direction" :data-reversed="reversed ? 'true' : 'false'"> <template v-for="(item, i) in items"> <slot :item="item"></slot> <div class="separator" v-if="showDate(i, item)" :key="item.id + '_date'"> <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> + <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> -</component> +</transition-group> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons'; -export default Vue.extend({ +export default defineComponent({ props: { items: { type: Array, @@ -82,14 +82,14 @@ export default Vue.extend({ } &[data-direction="up"] { - > .list-enter { + > .list-enter-from { opacity: 0; transform: translateY(64px); } } &[data-direction="down"] { - > .list-enter { + > .list-enter-from { opacity: 0; transform: translateY(-64px); } diff --git a/src/client/components/deck/antenna-column.vue b/src/client/components/deck/antenna-column.vue index dd38a087e9..e7cb9d8482 100644 --- a/src/client/components/deck/antenna-column.vue +++ b/src/client/components/deck/antenna-column.vue @@ -1,20 +1,21 @@ <template> -<x-column :menu="menu" :column="column" :is-stacked="isStacked"> +<XColumn :menu="menu" :column="column" :is-stacked="isStacked"> <template #header> - <fa :icon="faSatellite"/><span style="margin-left: 8px;">{{ column.name }}</span> + <Fa :icon="faSatellite"/><span style="margin-left: 8px;">{{ column.name }}</span> </template> - <x-timeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => $emit('loaded')"/> -</x-column> + <XTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => $emit('loaded')"/> +</XColumn> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faSatellite, faCog } from '@fortawesome/free-solid-svg-icons'; import XColumn from './column.vue'; import XTimeline from '../timeline.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XColumn, XTimeline, @@ -59,8 +60,8 @@ export default Vue.extend({ methods: { async setAntenna() { - const antennas = await this.$root.api('antennas/list'); - const { canceled, result: antenna } = await this.$root.dialog({ + const antennas = await os.api('antennas/list'); + const { canceled, result: antenna } = await os.dialog({ title: this.$t('selectAntenna'), type: null, select: { @@ -72,7 +73,7 @@ export default Vue.extend({ showCancelButton: true }); if (canceled) return; - Vue.set(this.column, 'antennaId', antenna.id); + this.column.antennaId = antenna.id; this.$store.commit('deviceUser/updateDeckColumn', this.column); }, diff --git a/src/client/components/deck/column-core.vue b/src/client/components/deck/column-core.vue index 44f19e7eda..36872d987c 100644 --- a/src/client/components/deck/column-core.vue +++ b/src/client/components/deck/column-core.vue @@ -1,17 +1,17 @@ <template> <!-- TODO: リファクタの余地がありそう --> -<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 === 'tl'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> -<x-list-column v-else-if="column.type === 'list'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> -<x-antenna-column v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> -<!-- TODO: <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"/> +<XWidgetsColumn v-if="column.type === 'widgets'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<XNotificationsColumn v-else-if="column.type === 'notifications'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<XTlColumn v-else-if="column.type === 'tl'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<XListColumn v-else-if="column.type === 'list'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<XAntennaColumn v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<!-- TODO: <XTlColumn v-else-if="column.type === 'hashtag'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> --> +<XMentionsColumn v-else-if="column.type === 'mentions'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<XDirectColumn v-else-if="column.type === 'direct'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import XTlColumn from './tl-column.vue'; import XAntennaColumn from './antenna-column.vue'; import XListColumn from './list-column.vue'; @@ -20,7 +20,7 @@ import XWidgetsColumn from './widgets-column.vue'; import XMentionsColumn from './mentions-column.vue'; import XDirectColumn from './direct-column.vue'; -export default Vue.extend({ +export default defineComponent({ components: { XTlColumn, XAntennaColumn, diff --git a/src/client/components/deck/column.vue b/src/client/components/deck/column.vue index 61b7ac9c69..a8ae7e4cc2 100644 --- a/src/client/components/deck/column.vue +++ b/src/client/components/deck/column.vue @@ -1,6 +1,6 @@ <template> <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> -<section class="dnpfarvg _panel _narrow_" :class="{ naked, paged: isMainColumn, _close_: !isMainColumn, active, isStacked, draghover, dragging, dropready }" +<section class="dnpfarvg _panel _narrow_" :class="{ paged: isMainColumn, naked, _close_: !isMainColumn, active, isStacked, draghover, dragging, dropready }" @dragover.prevent.stop="onDragover" @dragleave="onDragleave" @drop.prevent.stop="onDrop" @@ -15,15 +15,14 @@ @contextmenu.prevent.stop="onContextmenu" > <button class="toggleActive _button" @click="toggleActive" v-if="isStacked"> - <template v-if="active"><fa :icon="faAngleUp"/></template> - <template v-else><fa :icon="faAngleDown"/></template> + <template v-if="active"><Fa :icon="faAngleUp"/></template> + <template v-else><Fa :icon="faAngleDown"/></template> </button> <div class="action"> <slot name="action"></slot> </div> <span class="header"><slot name="header"></slot></span> - <button v-if="!isMainColumn" class="menu _button" ref="menu" @click.stop="showMenu"><fa :icon="faCaretDown"/></button> - <button v-else-if="$route.name !== 'index'" class="close _button" @click.stop="close"><fa :icon="faTimes"/></button> + <button v-if="!isMainColumn" class="menu _button" ref="menu" @click.stop="showMenu"><Fa :icon="faCaretDown"/></button> </header> <div ref="body" v-show="active"> <slot></slot> @@ -32,11 +31,12 @@ </template> <script lang="ts"> -import Vue from 'vue'; -import { faArrowUp, faArrowDown, faAngleUp, faAngleDown, faCaretDown, faTimes, faArrowRight, faArrowLeft, faPencilAlt } from '@fortawesome/free-solid-svg-icons'; +import { defineComponent } from 'vue'; +import { faArrowUp, faArrowDown, faAngleUp, faAngleDown, faCaretDown, faArrowRight, faArrowLeft, faPencilAlt } from '@fortawesome/free-solid-svg-icons'; import { faWindowMaximize, faTrashAlt, faWindowRestore } from '@fortawesome/free-regular-svg-icons'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { column: { type: Object, @@ -71,7 +71,7 @@ export default Vue.extend({ dragging: false, draghover: false, dropready: false, - faArrowUp, faArrowDown, faAngleUp, faAngleDown, faCaretDown, faTimes, + faArrowUp, faArrowDown, faAngleUp, faAngleDown, faCaretDown, }; }, @@ -86,10 +86,10 @@ export default Vue.extend({ 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'), + 'shift+up': () => this.$parent.$emit('parent-focus', 'up'), + 'shift+down': () => this.$parent.$emit('parent-focus', 'down'), + 'shift+left': () => this.$parent.$emit('parent-focus', 'left'), + 'shift+right': () => this.$parent.$emit('parent-focus', 'right'), }; } }, @@ -100,21 +100,21 @@ export default Vue.extend({ }, dragging(v) { - this.$root.$emit(v ? 'deck.column.dragStart' : 'deck.column.dragEnd'); + os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd'); } }, mounted() { if (!this.isMainColumn) { - this.$root.$on('deck.column.dragStart', this.onOtherDragStart); - this.$root.$on('deck.column.dragEnd', this.onOtherDragEnd); + os.deckGlobalEvents.on('column.dragStart', this.onOtherDragStart); + os.deckGlobalEvents.on('column.dragEnd', this.onOtherDragEnd); } }, - beforeDestroy() { + beforeUnmount() { if (!this.isMainColumn) { - this.$root.$off('deck.column.dragStart', this.onOtherDragStart); - this.$root.$off('deck.column.dragEnd', this.onOtherDragEnd); + os.deckGlobalEvents.off('column.dragStart', this.onOtherDragStart); + os.deckGlobalEvents.off('column.dragEnd', this.onOtherDragEnd); } }, @@ -137,7 +137,7 @@ export default Vue.extend({ icon: faPencilAlt, text: this.$t('rename'), action: () => { - this.$root.dialog({ + os.dialog({ title: this.$t('rename'), input: { default: this.column.name, @@ -207,14 +207,7 @@ export default Vue.extend({ }, showMenu() { - this.$root.menu({ - items: this.getMenu(), - source: this.$refs.menu, - }); - }, - - close() { - this.$router.push('/'); + os.modalMenu(this.getMenu(), this.$refs.menu); }, goTop() { @@ -232,7 +225,7 @@ export default Vue.extend({ } e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('mk-deck-column', this.column.id); + e.dataTransfer.setData(_DATA_TRANSFER_DECK_COLUMN_, this.column.id); this.dragging = true; }, @@ -254,7 +247,7 @@ export default Vue.extend({ return; } - const isDeckColumn = e.dataTransfer.types[0] == 'mk-deck-column'; + const isDeckColumn = e.dataTransfer.types[0] == _DATA_TRANSFER_DECK_COLUMN_; e.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none'; @@ -267,9 +260,9 @@ export default Vue.extend({ onDrop(e) { this.draghover = false; - this.$root.$emit('deck.column.dragEnd'); + os.deckGlobalEvents.emit('column.dragEnd'); - const id = e.dataTransfer.getData('mk-deck-column'); + const id = e.dataTransfer.getData(_DATA_TRANSFER_DECK_COLUMN_); if (id != null && id != '') { this.$store.commit('deviceUser/swapDeckColumn', { a: this.column.id, @@ -285,9 +278,11 @@ export default Vue.extend({ .dnpfarvg { $header-height: 42px; + --section-padding: 10px; + height: 100%; overflow: hidden; - box-shadow: 0 0 0 1px var(--deckColumnBorder); + contain: content; &.draghover { box-shadow: 0 0 0 2px var(--focus); @@ -341,7 +336,6 @@ export default Vue.extend({ &.paged { > div { background: var(--bg); - padding: var(--margin); } } @@ -379,8 +373,7 @@ export default Vue.extend({ > .toggleActive, > .action > *, - > .menu, - > .close { + > .menu { z-index: 1; width: $header-height; line-height: $header-height; @@ -408,8 +401,7 @@ export default Vue.extend({ display: none; } - > .menu, - > .close { + > .menu { margin-left: auto; margin-right: -16px; } diff --git a/src/client/components/deck/direct-column.vue b/src/client/components/deck/direct-column.vue index daf83cabff..ec598cebcb 100644 --- a/src/client/components/deck/direct-column.vue +++ b/src/client/components/deck/direct-column.vue @@ -1,19 +1,20 @@ <template> -<x-column :name="name" :column="column" :is-stacked="isStacked" :menu="menu"> - <template #header><fa :icon="faEnvelope" style="margin-right: 8px;"/>{{ column.name }}</template> +<XColumn :name="name" :column="column" :is-stacked="isStacked" :menu="menu"> + <template #header><Fa :icon="faEnvelope" style="margin-right: 8px;"/>{{ column.name }}</template> - <x-notes :pagination="pagination" @before="before()" @after="after()"/> -</x-column> + <XNotes :pagination="pagination" @before="before()" @after="after()"/> +</XColumn> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faEnvelope } from '@fortawesome/free-solid-svg-icons'; -import Progress from '../../scripts/loading'; +import Progress from '@/scripts/loading'; import XColumn from './column.vue'; import XNotes from '../notes.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XColumn, XNotes diff --git a/src/client/components/deck/list-column.vue b/src/client/components/deck/list-column.vue index a6e50802e0..4b8fc4a895 100644 --- a/src/client/components/deck/list-column.vue +++ b/src/client/components/deck/list-column.vue @@ -1,20 +1,21 @@ <template> -<x-column :menu="menu" :column="column" :is-stacked="isStacked"> +<XColumn :menu="menu" :column="column" :is-stacked="isStacked"> <template #header> - <fa :icon="faListUl"/><span style="margin-left: 8px;">{{ column.name }}</span> + <Fa :icon="faListUl"/><span style="margin-left: 8px;">{{ column.name }}</span> </template> - <x-timeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" @after="() => $emit('loaded')"/> -</x-column> + <XTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" @after="() => $emit('loaded')"/> +</XColumn> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faListUl, faCog } from '@fortawesome/free-solid-svg-icons'; import XColumn from './column.vue'; import XTimeline from '../timeline.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XColumn, XTimeline, @@ -59,8 +60,8 @@ export default Vue.extend({ methods: { async setList() { - const lists = await this.$root.api('users/lists/list'); - const { canceled, result: list } = await this.$root.dialog({ + const lists = await os.api('users/lists/list'); + const { canceled, result: list } = await os.dialog({ title: this.$t('selectList'), type: null, select: { @@ -72,7 +73,7 @@ export default Vue.extend({ showCancelButton: true }); if (canceled) return; - Vue.set(this.column, 'listId', list.id); + this.column.listId = list.id; this.$store.commit('deviceUser/updateDeckColumn', this.column); }, diff --git a/src/client/components/deck/mentions-column.vue b/src/client/components/deck/mentions-column.vue index 7f5382a155..dfa82af9b9 100644 --- a/src/client/components/deck/mentions-column.vue +++ b/src/client/components/deck/mentions-column.vue @@ -1,19 +1,20 @@ <template> -<x-column :column="column" :is-stacked="isStacked" :menu="menu"> - <template #header><fa :icon="faAt" style="margin-right: 8px;"/>{{ column.name }}</template> +<XColumn :column="column" :is-stacked="isStacked" :menu="menu"> + <template #header><Fa :icon="faAt" style="margin-right: 8px;"/>{{ column.name }}</template> - <x-notes :pagination="pagination" @before="before()" @after="after()"/> -</x-column> + <XNotes :pagination="pagination" @before="before()" @after="after()"/> +</XColumn> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faAt } from '@fortawesome/free-solid-svg-icons'; -import Progress from '../../scripts/loading'; +import Progress from '@/scripts/loading'; import XColumn from './column.vue'; import XNotes from '../notes.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XColumn, XNotes diff --git a/src/client/components/deck/notifications-column.vue b/src/client/components/deck/notifications-column.vue index ac49aec06d..23fcb15681 100644 --- a/src/client/components/deck/notifications-column.vue +++ b/src/client/components/deck/notifications-column.vue @@ -1,19 +1,20 @@ <template> -<x-column :column="column" :is-stacked="isStacked" :menu="menu"> - <template #header><fa :icon="faBell" style="margin-right: 8px;"/>{{ column.name }}</template> +<XColumn :column="column" :is-stacked="isStacked" :menu="menu"> + <template #header><Fa :icon="faBell" style="margin-right: 8px;"/>{{ column.name }}</template> - <x-notifications :include-types="column.includingTypes"/> -</x-column> + <XNotifications :include-types="column.includingTypes"/> +</XColumn> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faCog } from '@fortawesome/free-solid-svg-icons'; import { faBell } from '@fortawesome/free-regular-svg-icons'; import XColumn from './column.vue'; import XNotifications from '../notifications.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XColumn, XNotifications @@ -42,12 +43,17 @@ export default Vue.extend({ icon: faCog, text: this.$t('notificationSetting'), action: async () => { - this.$root.new(await import('../notification-setting-window.vue').then(m => m.default), { + os.popup(await import('@/components/notification-setting-window.vue'), { includingTypes: this.column.includingTypes, - }).$on('ok', async ({ includingTypes }) => { - this.$set(this.column, 'includingTypes', includingTypes); - this.$store.commit('deviceUser/updateDeckColumn', this.column); - }); + }, { + done: async (res) => { + const { includingTypes } = res; + this.$store.commit('deviceUser/updateDeckColumn', { + ...this.column, + includingTypes: includingTypes + }); + }, + }, 'closed'); } }]; }, diff --git a/src/client/components/deck/tl-column.vue b/src/client/components/deck/tl-column.vue index d2d1749c7c..f757618881 100644 --- a/src/client/components/deck/tl-column.vue +++ b/src/client/components/deck/tl-column.vue @@ -1,31 +1,32 @@ <template> -<x-column :menu="menu" :column="column" :is-stacked="isStacked" :indicated="indicated" @change-active-state="onChangeActiveState"> +<XColumn :menu="menu" :column="column" :is-stacked="isStacked" :indicated="indicated" @change-active-state="onChangeActiveState"> <template #header> - <fa v-if="column.tl === 'home'" :icon="faHome"/> - <fa v-else-if="column.tl === 'local'" :icon="faComments"/> - <fa v-else-if="column.tl === 'social'" :icon="faShareAlt"/> - <fa v-else-if="column.tl === 'global'" :icon="faGlobe"/> + <Fa v-if="column.tl === 'home'" :icon="faHome"/> + <Fa v-else-if="column.tl === 'local'" :icon="faComments"/> + <Fa v-else-if="column.tl === 'social'" :icon="faShareAlt"/> + <Fa v-else-if="column.tl === 'global'" :icon="faGlobe"/> <span style="margin-left: 8px;">{{ column.name }}</span> </template> <div class="iwaalbte" v-if="disabled"> <p> - <fa :icon="faMinusCircle"/> + <Fa :icon="faMinusCircle"/> {{ $t('disabled-timeline.title') }} </p> <p class="desc">{{ $t('disabled-timeline.description') }}</p> </div> - <x-timeline v-else-if="column.tl" ref="timeline" :src="column.tl" @after="() => $emit('loaded')" @queue="queueUpdated" @note="onNote" :key="column.tl"/> -</x-column> + <XTimeline v-else-if="column.tl" ref="timeline" :src="column.tl" @after="() => $emit('loaded')" @queue="queueUpdated" @note="onNote" :key="column.tl"/> +</XColumn> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faMinusCircle, faHome, faComments, faShareAlt, faGlobe, faCog } from '@fortawesome/free-solid-svg-icons'; import XColumn from './column.vue'; import XTimeline from '../timeline.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XColumn, XTimeline, @@ -78,7 +79,7 @@ export default Vue.extend({ methods: { async setType() { - const { canceled, result: src } = await this.$root.dialog({ + const { canceled, result: src } = await os.dialog({ title: this.$t('timeline'), type: null, select: { @@ -99,7 +100,7 @@ export default Vue.extend({ } return; } - Vue.set(this.column, 'tl', src); + this.column.tl = src; this.$store.commit('deviceUser/updateDeckColumn', this.column); }, diff --git a/src/client/components/deck/widgets-column.vue b/src/client/components/deck/widgets-column.vue index 31d1e3d53c..e19fb01e5e 100644 --- a/src/client/components/deck/widgets-column.vue +++ b/src/client/components/deck/widgets-column.vue @@ -1,47 +1,46 @@ <template> -<x-column :menu="menu" :naked="true" :column="column" :is-stacked="isStacked"> - <template #header><fa :icon="faWindowMaximize" style="margin-right: 8px;"/>{{ column.name }}</template> +<XColumn :menu="menu" :naked="true" :column="column" :is-stacked="isStacked"> + <template #header><Fa :icon="faWindowMaximize" style="margin-right: 8px;"/>{{ column.name }}</template> <div class="wtdtxvec"> <template v-if="edit"> <header> - <mk-select v-model="widgetAdderSelected" style="margin-bottom: var(--margin)"> + <MkSelect v-model:value="widgetAdderSelected" style="margin-bottom: var(--margin)"> <template #label>{{ $t('selectWidget') }}</template> <option v-for="widget in widgets" :value="widget" :key="widget">{{ $t(`_widgets.${widget}`) }}</option> - </mk-select> - <mk-button inline @click="addWidget" primary><fa :icon="faPlus"/> {{ $t('add') }}</mk-button> - <mk-button inline @click="edit = false">{{ $t('close') }}</mk-button> + </MkSelect> + <MkButton inline @click="addWidget" primary><Fa :icon="faPlus"/> {{ $t('add') }}</MkButton> + <MkButton inline @click="edit = false">{{ $t('close') }}</MkButton> </header> - <x-draggable + <XDraggable :list="column.widgets" animation="150" @sort="onWidgetSort" > <div v-for="widget in column.widgets" class="customize-container" :key="widget.id" @click="widgetFunc(widget.id)"> - <button class="remove _button" @click.prevent.stop="removeWidget(widget)"><fa :icon="faTimes"/></button> - <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" :column="column"/> + <button class="remove _button" @click.prevent.stop="removeWidget(widget)"><Fa :icon="faTimes"/></button> + <component :is="`mkw-${widget.name}`" :widget="widget" :setting-callback="setting => settings[widget.id] = setting" :column="column"/> </div> - </x-draggable> + </XDraggable> </template> - <component v-else class="widget" v-for="widget in column.widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" :column="column"/> + <component v-else class="widget" v-for="widget in column.widgets" :is="`mkw-${widget.name}`" :key="widget.id" :widget="widget" :column="column"/> </div> -</x-column> +</XColumn> </template> <script lang="ts"> -import Vue from 'vue'; -import * as XDraggable from 'vuedraggable'; +import { defineComponent, defineAsyncComponent } from 'vue'; import { v4 as uuid } from 'uuid'; import { faWindowMaximize, faTimes, faCog, faPlus } from '@fortawesome/free-solid-svg-icons'; -import MkSelect from '../../components/ui/select.vue'; -import MkButton from '../../components/ui/button.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkButton from '@/components/ui/button.vue'; import XColumn from './column.vue'; import { widgets } from '../../widgets'; -export default Vue.extend({ +export default defineComponent({ components: { XColumn, - XDraggable, + XDraggable: defineAsyncComponent(() => import('vue-draggable-next').then(x => x.VueDraggableNext)), MkSelect, MkButton, }, @@ -63,6 +62,7 @@ export default Vue.extend({ menu: null, widgetAdderSelected: null, widgets, + settings: {}, faWindowMaximize, faTimes, faPlus }; }, @@ -79,7 +79,7 @@ export default Vue.extend({ methods: { widgetFunc(id) { - this.$refs[id][0].setting(); + this.settings[id](); }, onWidgetSort() { diff --git a/src/client/components/dialog.vue b/src/client/components/dialog.vue index 0ca9a5c69e..3517aa3339 100644 --- a/src/client/components/dialog.vue +++ b/src/client/components/dialog.vue @@ -1,69 +1,60 @@ <template> -<div class="mk-dialog" :class="{ iconOnly }"> - <transition :name="$store.state.device.animation ? 'bg-fade' : ''" appear> - <div class="bg _modalBg" ref="bg" @click="onBgClick" v-if="show"></div> - </transition> - <transition :name="$store.state.device.animation ? 'dialog' : ''" appear @after-leave="() => { destroyDom(); }"> - <div class="main" ref="main" v-if="show"> - <template v-if="type == 'signin'"> - <mk-signin/> +<MkModal ref="modal" @click="done(true)" @closed="$emit('closed')"> + <div class="mk-dialog"> + <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> + <MkInput v-if="input" v-model:value="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></MkInput> + <MkInput v-if="user" v-model:value="userInputValue" autofocus @keydown="onInputKeydown"><template #prefix>@</template></MkInput> + <MkSelect v-if="select" v-model:value="selectedValue" autofocus> + <template v-if="select.items"> + <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> </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> + <optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label"> + <option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option> + </optgroup> </template> + </MkSelect> + <div class="buttons" v-if="(showOkButton || showCancelButton) && !actions"> + <MkButton inline @click="ok" v-if="showOkButton" primary :autofocus="!input && !select && !user" :disabled="!canOk">{{ (showCancelButton || input || select || user) ? $t('ok') : $t('gotIt') }}</MkButton> + <MkButton inline @click="cancel" v-if="showCancelButton || input || select || user">{{ $t('cancel') }}</MkButton> + </div> + <div class="buttons" v-if="actions"> + <MkButton v-for="action in actions" inline @click="() => { action.callback(); close(); }" :primary="action.primary" :key="action.text">{{ action.text }}</MkButton> </div> - </transition> -</div> + </div> +</MkModal> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } 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 MkSignin from './signin.vue'; +import MkModal from '@/components/ui/modal.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkSelect from '@/components/ui/select.vue'; import parseAcct from '../../misc/acct/parse'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { + MkModal, MkButton, MkInput, MkSelect, - MkSignin, }, props: { @@ -107,19 +98,12 @@ export default Vue.extend({ type: Boolean, default: true }, - iconOnly: { - type: Boolean, - default: false - }, - autoClose: { - type: Boolean, - default: false - } }, + emits: ['done', 'closed'], + 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, @@ -131,63 +115,51 @@ export default Vue.extend({ watch: { userInputValue() { if (this.user) { - this.$root.api('users/show', parseAcct(this.userInputValue)).then(u => { + os.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() { + beforeUnmount() { document.removeEventListener('keydown', this.onKeydown); }, methods: { + done(canceled, result?) { + this.$emit('done', { canceled, result }); + this.$refs.modal.close(); + }, + async ok() { if (!this.canOk) return; if (!this.showOkButton) return; if (this.user) { - const user = await this.$root.api('users/show', parseAcct(this.userInputValue)); + const user = await os.api('users/show', parseAcct(this.userInputValue)); if (user) { - this.$emit('ok', user); - this.close(); + this.done(false, user); } } else { const result = this.input ? this.inputValue : this.select ? this.selectedValue : true; - this.$emit('ok', result); - this.close(); + this.done(false, result); } }, 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'; + this.done(true); }, onBgClick() { @@ -214,95 +186,60 @@ export default Vue.extend({ </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; - } - - > .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); + position: relative; + padding: 32px; + min-width: 320px; + max-width: 480px; + box-sizing: border-box; + text-align: center; + background: var(--panel); + border-radius: var(--radius); - > .icon { - font-size: 32px; + > .icon { + font-size: 32px; - &.success { - color: var(--accent); - } + &.success { + color: var(--accent); + } - &.error { - color: #ec4137; - } + &.error { + color: #ec4137; + } - &.warning { - color: #ecb637; - } + &.warning { + color: #ecb637; + } - > * { - display: block; - margin: 0 auto; - } + > * { + display: block; + margin: 0 auto; + } - & + header { - margin-top: 16px; - } + & + header { + margin-top: 16px; } + } - > header { - margin: 0 0 8px 0; - font-weight: bold; - font-size: 20px; + > header { + margin: 0 0 8px 0; + font-weight: bold; + font-size: 20px; - & + .body { - margin-top: 8px; - } + & + .body { + margin-top: 8px; } + } - > .body { - margin: 16px 0 0 0; - } + > .body { + margin: 16px 0 0 0; + } - > .buttons { - margin-top: 16px; + > .buttons { + margin-top: 16px; - > * { - margin: 0 8px; - } + > * { + margin: 0 8px; } } } diff --git a/src/client/components/drive-file-thumbnail.vue b/src/client/components/drive-file-thumbnail.vue index 4bc1e569b7..7615014ba9 100644 --- a/src/client/components/drive-file-thumbnail.vue +++ b/src/client/components/drive-file-thumbnail.vue @@ -1,20 +1,20 @@ <template> <div class="zdjebgpv" ref="thumbnail"> - <img-with-blurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :style="`object-fit: ${ fit }`"/> - <fa :icon="faFileImage" class="icon" v-else-if="is === 'image'"/> - <fa :icon="faFileVideo" class="icon" v-else-if="is === 'video'"/> - <fa :icon="faMusic" class="icon" v-else-if="is === 'audio' || is === 'midi'"/> - <fa :icon="faFileCsv" class="icon" v-else-if="is === 'csv'"/> - <fa :icon="faFilePdf" class="icon" v-else-if="is === 'pdf'"/> - <fa :icon="faFileAlt" class="icon" v-else-if="is === 'textfile'"/> - <fa :icon="faFileArchive" class="icon" v-else-if="is === 'archive'"/> - <fa :icon="faFile" class="icon" v-else/> - <fa :icon="faFilm" class="icon-sub" v-if="isThumbnailAvailable && is === 'video'"/> + <ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :style="`object-fit: ${ fit }`"/> + <Fa :icon="faFileImage" class="icon" v-else-if="is === 'image'"/> + <Fa :icon="faFileVideo" class="icon" v-else-if="is === 'video'"/> + <Fa :icon="faMusic" class="icon" v-else-if="is === 'audio' || is === 'midi'"/> + <Fa :icon="faFileCsv" class="icon" v-else-if="is === 'csv'"/> + <Fa :icon="faFilePdf" class="icon" v-else-if="is === 'pdf'"/> + <Fa :icon="faFileAlt" class="icon" v-else-if="is === 'textfile'"/> + <Fa :icon="faFileArchive" class="icon" v-else-if="is === 'archive'"/> + <Fa :icon="faFile" class="icon" v-else/> + <Fa :icon="faFilm" class="icon-sub" v-if="isThumbnailAvailable && is === 'video'"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faFile, faFileAlt, @@ -28,7 +28,7 @@ import { } from '@fortawesome/free-solid-svg-icons'; import ImgWithBlurhash from './img-with-blurhash.vue'; -export default Vue.extend({ +export default defineComponent({ components: { ImgWithBlurhash }, diff --git a/src/client/components/drive-window.vue b/src/client/components/drive-window.vue index c42cb66617..d989d982d9 100644 --- a/src/client/components/drive-window.vue +++ b/src/client/components/drive-window.vue @@ -1,31 +1,41 @@ <template> -<x-window ref="window" :width="800" :height="500" @closed="() => { $emit('closed'); destroyDom(); }" :with-ok-button="true" :ok-button-disabled="(type === 'file') && (selected.length === 0)" @ok="ok()"> +<XModalWindow ref="dialog" + :width="800" + :height="500" + :with-ok-button="true" + :ok-button-disabled="(type === 'file') && (selected.length === 0)" + @click="cancel()" + @close="cancel()" + @ok="ok()" + @closed="$emit('closed')" +> <template #header> {{ multiple ? ((type === 'file') ? $t('selectFiles') : $t('selectFolders')) : ((type === 'file') ? $t('selectFile') : $t('selectFolder')) }} - <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length | number }})</span> + <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span> </template> <div> - <x-drive :multiple="multiple" @change-selection="onChangeSelection" :select="type"/> + <XDrive :multiple="multiple" @changeSelection="onChangeSelection" @selected="ok()" :select="type"/> </div> -</x-window> +</XModalWindow> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import XDrive from './drive.vue'; -import XWindow from './window.vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import number from '@/filters/number'; -export default Vue.extend({ +export default defineComponent({ components: { XDrive, - XWindow, + XModalWindow, }, props: { type: { type: String, required: false, - default: 'file' + default: 'file' }, multiple: { type: Boolean, @@ -33,6 +43,8 @@ export default Vue.extend({ } }, + emits: ['done', 'closed'], + data() { return { selected: [] @@ -41,13 +53,20 @@ export default Vue.extend({ methods: { ok() { - this.$emit('selected', this.selected); - this.$refs.window.close(); + this.$emit('done', this.selected); + this.$refs.dialog.close(); + }, + + cancel() { + this.$emit('done'); + this.$refs.dialog.close(); }, onChangeSelection(xs) { this.selected = xs; - } + }, + + number } }); </script> diff --git a/src/client/components/drive.file.vue b/src/client/components/drive.file.vue index b31a4e6375..261bbc83ec 100644 --- a/src/client/components/drive.file.vue +++ b/src/client/components/drive.file.vue @@ -1,7 +1,8 @@ <template> <div class="ncvczrfv" - :data-is-selected="isSelected" + :class="{ isSelected }" @click="onClick" + @contextmenu.stop="onContextmenu" draggable="true" @dragstart="onDragstart" @dragend="onDragend" @@ -20,7 +21,7 @@ <p>{{ $t('nsfw') }}</p> </div> - <x-file-thumbnail class="thumbnail" :file="file" fit="contain"/> + <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> <p class="name"> <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> @@ -30,17 +31,17 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; -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'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import MkDriveFileThumbnail from './drive-file-thumbnail.vue'; +import bytes from '../filters/bytes'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { - XFileThumbnail + MkDriveFileThumbnail }, props: { @@ -60,6 +61,8 @@ export default Vue.extend({ } }, + emits: ['chosen'], + data() { return { isDragging: false @@ -72,48 +75,54 @@ export default Vue.extend({ return this.$parent; }, title(): string { - return `${this.file.name}\n${this.file.type} ${Vue.filter('bytes')(this.file.size)}`; + return `${this.file.name}\n${this.file.type} ${bytes(this.file.size)}`; } }, methods: { + getMenu() { + return [{ + text: this.$t('rename'), + icon: faICursor, + action: this.rename + }, { + text: this.file.isSensitive ? this.$t('unmarkAsSensitive') : this.$t('markAsSensitive'), + icon: this.file.isSensitive ? faEye : faEyeSlash, + action: this.toggleSensitive + }, null, { + 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, { + text: this.$t('delete'), + icon: faTrashAlt, + danger: true, + action: this.deleteFile + }]; + }, + onClick(ev) { if (this.selectMode) { this.$emit('chosen', this.file); } else { - this.$root.menu({ - items: [{ - text: this.$t('rename'), - icon: faICursor, - action: this.rename - }, { - text: this.file.isSensitive ? this.$t('unmarkAsSensitive') : this.$t('markAsSensitive'), - icon: this.file.isSensitive ? faEye : faEyeSlash, - action: this.toggleSensitive - }, null, { - 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, { - text: this.$t('delete'), - icon: faTrashAlt, - action: this.deleteFile - }], - source: ev.currentTarget || ev.target, - }); + os.modalMenu(this.getMenu(), ev.currentTarget || ev.target); } }, + onContextmenu(e) { + os.contextMenu(this.getMenu(), e); + }, + onDragstart(e) { e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('mk_drive_file', JSON.stringify(this.file)); + e.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(this.file)); this.isDragging = true; // 親ブラウザに対して、ドラッグが開始されたフラグを立てる @@ -127,7 +136,7 @@ export default Vue.extend({ }, rename() { - this.$root.dialog({ + os.dialog({ title: this.$t('renameFile'), input: { placeholder: this.$t('inputNewFileName'), @@ -136,7 +145,7 @@ export default Vue.extend({ } }).then(({ canceled, result: name }) => { if (canceled) return; - this.$root.api('drive/files/update', { + os.api('drive/files/update', { fileId: this.file.id, name: name }); @@ -144,7 +153,7 @@ export default Vue.extend({ }, toggleSensitive() { - this.$root.api('drive/files/update', { + os.api('drive/files/update', { fileId: this.file.id, isSensitive: !this.file.isSensitive }); @@ -152,18 +161,15 @@ export default Vue.extend({ copyUrl() { copyToClipboard(this.file.url); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); }, setAsAvatar() { - updateAvatar(this.$root)(this.file); + os.updateAvatar(this.file); }, setAsBanner() { - updateBanner(this.$root)(this.file); + os.updateBanner(this.file); }, addApp() { @@ -171,17 +177,19 @@ export default Vue.extend({ }, async deleteFile() { - const { canceled } = await this.$root.dialog({ + const { canceled } = await os.dialog({ type: 'warning', text: this.$t('driveFileDeleteConfirm', { name: this.file.name }), showCancelButton: true }); if (canceled) return; - this.$root.api('drive/files/delete', { + os.api('drive/files/delete', { fileId: this.file.id }); - } + }, + + bytes } }); </script> @@ -197,6 +205,10 @@ export default Vue.extend({ cursor: pointer; } + > * { + pointer-events: none; + } + &:hover { background: rgba(#000, 0.05); @@ -233,7 +245,7 @@ export default Vue.extend({ } } - &[data-is-selected] { + &.isSelected { background: var(--accent); &:hover { diff --git a/src/client/components/drive.folder.vue b/src/client/components/drive.folder.vue index 9e80653194..bf2c9b6494 100644 --- a/src/client/components/drive.folder.vue +++ b/src/client/components/drive.folder.vue @@ -1,6 +1,6 @@ <template> <div class="rghtznwe" - :data-draghover="draghover" + :class="{ draghover }" @click="onClick" @mouseover="onMouseover" @mouseout="onMouseout" @@ -14,8 +14,8 @@ :title="title" > <p class="name"> - <template v-if="hover"><fa :icon="faFolderOpen" fixed-width/></template> - <template v-if="!hover"><fa :icon="faFolder" 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"> @@ -26,10 +26,11 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faFolder, faFolderOpen } from '@fortawesome/free-regular-svg-icons'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { folder: { type: Object, @@ -47,6 +48,8 @@ export default Vue.extend({ } }, + emits: ['chosen'], + data() { return { hover: false, @@ -91,8 +94,8 @@ export default Vue.extend({ } const isFile = e.dataTransfer.items[0].kind == 'file'; - const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; - const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; if (isFile || isDriveFile || isDriveFolder) { e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; @@ -121,11 +124,11 @@ export default Vue.extend({ } //#region ドライブのファイル - const driveFile = e.dataTransfer.getData('mk_drive_file'); + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); if (driveFile != null && driveFile != '') { const file = JSON.parse(driveFile); this.browser.removeFile(file.id); - this.$root.api('drive/files/update', { + os.api('drive/files/update', { fileId: file.id, folderId: this.folder.id }); @@ -133,7 +136,7 @@ export default Vue.extend({ //#endregion //#region ドライブのフォルダ - const driveFolder = e.dataTransfer.getData('mk_drive_folder'); + const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); if (driveFolder != null && driveFolder != '') { const folder = JSON.parse(driveFolder); @@ -141,7 +144,7 @@ export default Vue.extend({ if (folder.id == this.folder.id) return; this.browser.removeFolder(folder.id); - this.$root.api('drive/folders/update', { + os.api('drive/folders/update', { folderId: folder.id, parentId: this.folder.id }).then(() => { @@ -149,15 +152,15 @@ export default Vue.extend({ }).catch(err => { switch (err) { case 'detected-circular-definition': - this.$root.dialog({ + os.dialog({ title: this.$t('unableToProcess'), text: this.$t('circularReferenceFolder') }); break; default: - this.$root.dialog({ + os.dialog({ type: 'error', - text: this.$t('error') + text: this.$t('somethingHappened') }); } }); @@ -167,7 +170,7 @@ export default Vue.extend({ onDragstart(e) { e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('mk_drive_folder', JSON.stringify(this.folder)); + e.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(this.folder)); this.isDragging = true; // 親ブラウザに対して、ドラッグが開始されたフラグを立てる @@ -189,7 +192,7 @@ export default Vue.extend({ }, rename() { - this.$root.dialog({ + os.dialog({ title: this.$t('renameFolder'), input: { placeholder: this.$t('inputNewFolderName'), @@ -197,7 +200,7 @@ export default Vue.extend({ } }).then(({ canceled, result: name }) => { if (canceled) return; - this.$root.api('drive/folders/update', { + os.api('drive/folders/update', { folderId: this.folder.id, name: name }); @@ -205,7 +208,7 @@ export default Vue.extend({ }, deleteFolder() { - this.$root.api('drive/folders/delete', { + os.api('drive/folders/delete', { folderId: this.folder.id }).then(() => { if (this.$store.state.settings.uploadFolder === this.folder.id) { @@ -217,14 +220,14 @@ export default Vue.extend({ }).catch(err => { switch(err.id) { case 'b0fc8a17-963c-405d-bfbc-859a487295e1': - this.$root.dialog({ + os.dialog({ type: 'error', title: this.$t('unableToDelete'), text: this.$t('hasChildFilesOrFolders') }); break; default: - this.$root.dialog({ + os.dialog({ type: 'error', text: this.$t('unableToDelete') }); @@ -272,7 +275,7 @@ export default Vue.extend({ } } - &[data-draghover] { + &.draghover { &:after { content: ""; pointer-events: none; diff --git a/src/client/components/drive.nav-folder.vue b/src/client/components/drive.nav-folder.vue index 9e805a5e93..16ebc433fa 100644 --- a/src/client/components/drive.nav-folder.vue +++ b/src/client/components/drive.nav-folder.vue @@ -1,22 +1,23 @@ <template> <div class="drylbebk" - :data-draghover="draghover" + :class="{ draghover }" @click="onClick" @dragover.prevent.stop="onDragover" @dragenter="onDragenter" @dragleave="onDragleave" @drop.stop="onDrop" > - <i v-if="folder == null"><fa :icon="faCloud"/></i> + <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 { defineComponent } from 'vue'; import { faCloud } from '@fortawesome/free-solid-svg-icons'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { folder: { type: Object, @@ -58,8 +59,8 @@ export default Vue.extend({ } const isFile = e.dataTransfer.items[0].kind == 'file'; - const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; - const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; if (isFile || isDriveFile || isDriveFolder) { e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; @@ -90,11 +91,11 @@ export default Vue.extend({ } //#region ドライブのファイル - const driveFile = e.dataTransfer.getData('mk_drive_file'); + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); if (driveFile != null && driveFile != '') { const file = JSON.parse(driveFile); this.browser.removeFile(file.id); - this.$root.api('drive/files/update', { + os.api('drive/files/update', { fileId: file.id, folderId: this.folder ? this.folder.id : null }); @@ -102,13 +103,13 @@ export default Vue.extend({ //#endregion //#region ドライブのフォルダ - const driveFolder = e.dataTransfer.getData('mk_drive_folder'); + const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); if (driveFolder != null && driveFolder != '') { const folder = JSON.parse(driveFolder); // 移動先が自分自身ならreject if (this.folder && folder.id == this.folder.id) return; this.browser.removeFolder(folder.id); - this.$root.api('drive/folders/update', { + os.api('drive/folders/update', { folderId: folder.id, parentId: this.folder ? this.folder.id : null }); @@ -125,7 +126,7 @@ export default Vue.extend({ pointer-events: none; } - &[data-draghover] { + &.draghover { background: #eee; } diff --git a/src/client/components/drive.vue b/src/client/components/drive.vue index 3e7b7d04ae..2c3203b387 100644 --- a/src/client/components/drive.vue +++ b/src/client/components/drive.vue @@ -2,34 +2,35 @@ <div class="yfudmmck"> <nav> <div class="path" @contextmenu.prevent.stop="() => {}"> - <x-nav-folder :class="{ current: folder == null }"/> + <XNavFolder :class="{ current: folder == null }"/> <template v-for="f in hierarchyFolders"> - <span class="separator" :key="f.id + ':separator'"><fa :icon="faAngleRight"/></span> - <x-nav-folder :folder="f" :key="f.id"/> + <span class="separator"><Fa :icon="faAngleRight"/></span> + <XNavFolder :folder="f"/> </template> - <span class="separator" v-if="folder != null"><fa :icon="faAngleRight"/></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 }" + <div class="main _section" :class="{ uploading: uploadings.length > 0, fetching }" ref="main" @dragover.prevent.stop="onDragover" @dragenter="onDragenter" @dragleave="onDragleave" @drop.prevent.stop="onDrop" + @contextmenu="onContextmenu" > <div class="contents" ref="contents"> <div class="folders" ref="foldersContainer" v-show="folders.length > 0"> - <x-folder v-for="f in folders" :key="f.id" class="folder" :folder="f" :select-mode="select === 'folder'" :is-selected="selectedFolders.some(x => x.id === f.id)" @chosen="chooseFolder"/> + <XFolder v-for="f in folders" :key="f.id" class="folder" :folder="f" :select-mode="select === 'folder'" :is-selected="selectedFolders.some(x => x.id === f.id)" @chosen="chooseFolder"/> <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> <div class="padding" v-for="(n, i) in 16" :key="i"></div> - <mk-button ref="moreFolders" v-if="moreFolders">{{ $t('loadMore') }}</mk-button> + <MkButton ref="moreFolders" v-if="moreFolders">{{ $t('loadMore') }}</MkButton> </div> <div class="files" ref="filesContainer" v-show="files.length > 0"> - <x-file v-for="file in files" :key="file.id" class="file" :file="file" :select-mode="select === 'file'" :is-selected="selectedFiles.some(x => x.id === file.id)" @chosen="chooseFile"/> + <XFile v-for="file in files" :key="file.id" class="file" :file="file" :select-mode="select === 'file'" :is-selected="selectedFiles.some(x => x.id === file.id)" @chosen="chooseFile"/> <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> <div class="padding" v-for="(n, i) in 16" :key="i"></div> - <mk-button ref="loadMoreFiles" @click="fetchMoreFiles" v-show="moreFiles">{{ $t('loadMore') }}</mk-button> + <MkButton ref="loadMoreFiles" @click="fetchMoreFiles" v-show="moreFiles">{{ $t('loadMore') }}</MkButton> </div> <div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching"> <p v-if="draghover">{{ $t('empty-draghover') }}</p> @@ -37,29 +38,28 @@ <p v-if="!draghover && folder != null">{{ $t('emptyFolder') }}</p> </div> </div> - <mk-loading v-if="fetching"/> + <MkLoading v-if="fetching"/> </div> <div class="dropzone" v-if="draghover"></div> - <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 { faAngleRight } from '@fortawesome/free-solid-svg-icons'; +import { defineComponent } from 'vue'; +import { faAngleRight, faFolderPlus, faICursor, faLink, faUpload } from '@fortawesome/free-solid-svg-icons'; import XNavFolder from './drive.nav-folder.vue'; import XFolder from './drive.folder.vue'; import XFile from './drive.file.vue'; -import XUploader from './uploader.vue'; import MkButton from './ui/button.vue'; +import * as os from '@/os'; +import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; -export default Vue.extend({ +export default defineComponent({ components: { XNavFolder, XFolder, XFile, - XUploader, MkButton, }, @@ -85,6 +85,8 @@ export default Vue.extend({ } }, + emits: ['selected', 'change-selection', 'move-root', 'cd', 'open-folder'], + data() { return { /** @@ -100,7 +102,7 @@ export default Vue.extend({ hierarchyFolders: [], selectedFiles: [], selectedFolders: [], - uploadings: [], + uploadings: os.uploads, connection: null, /** @@ -140,7 +142,7 @@ export default Vue.extend({ }); } - this.connection = this.$root.stream.useSharedConnection('drive'); + this.connection = os.stream.useSharedConnection('drive'); this.connection.on('fileCreated', this.onStreamDriveFileCreated); this.connection.on('fileUpdated', this.onStreamDriveFileUpdated); @@ -164,7 +166,7 @@ export default Vue.extend({ } }, - beforeDestroy() { + beforeUnmount() { this.connection.dispose(); this.ilFilesObserver.disconnect(); }, @@ -204,14 +206,6 @@ export default Vue.extend({ this.removeFolder(folderId); }, - onChangeUploaderUploads(uploads) { - this.uploadings = uploads; - }, - - onUploaderUploaded(file) { - this.addFile(file, true); - }, - onDragover(e): any { // ドラッグ元が自分自身の所有するアイテムだったら if (this.isDragSource) { @@ -221,8 +215,8 @@ export default Vue.extend({ } const isFile = e.dataTransfer.items[0].kind == 'file'; - const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; - const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; if (isFile || isDriveFile || isDriveFolder) { e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; @@ -253,12 +247,12 @@ export default Vue.extend({ } //#region ドライブのファイル - const driveFile = e.dataTransfer.getData('mk_drive_file'); + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); if (driveFile != null && driveFile != '') { const file = JSON.parse(driveFile); if (this.files.some(f => f.id == file.id)) return; this.removeFile(file.id); - this.$root.api('drive/files/update', { + os.api('drive/files/update', { fileId: file.id, folderId: this.folder ? this.folder.id : null }); @@ -266,7 +260,7 @@ export default Vue.extend({ //#endregion //#region ドライブのフォルダ - const driveFolder = e.dataTransfer.getData('mk_drive_folder'); + const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); if (driveFolder != null && driveFolder != '') { const folder = JSON.parse(driveFolder); @@ -274,7 +268,7 @@ export default Vue.extend({ if (this.folder && folder.id == this.folder.id) return false; if (this.folders.some(f => f.id == folder.id)) return false; this.removeFolder(folder.id); - this.$root.api('drive/folders/update', { + os.api('drive/folders/update', { folderId: folder.id, parentId: this.folder ? this.folder.id : null }).then(() => { @@ -282,15 +276,15 @@ export default Vue.extend({ }).catch(err => { switch (err) { case 'detected-circular-definition': - this.$root.dialog({ + os.dialog({ title: this.$t('unableToProcess'), text: this.$t('circularReferenceFolder') }); break; default: - this.$root.dialog({ + os.dialog({ type: 'error', - text: this.$t('error') + text: this.$t('somethingHappened') }); } }); @@ -303,19 +297,19 @@ export default Vue.extend({ }, urlUpload() { - this.$root.dialog({ + os.dialog({ title: this.$t('uploadFromUrl'), input: { placeholder: this.$t('uploadFromUrlDescription') } }).then(({ canceled, result: url }) => { if (canceled) return; - this.$root.api('drive/files/upload_from_url', { + os.api('drive/files/upload_from_url', { url: url, folderId: this.folder ? this.folder.id : undefined }); - this.$root.dialog({ + os.dialog({ title: this.$t('uploadFromUrlRequested'), text: this.$t('uploadFromUrlMayTakeTime') }); @@ -323,14 +317,14 @@ export default Vue.extend({ }, createFolder() { - this.$root.dialog({ + os.dialog({ title: this.$t('createFolder'), input: { placeholder: this.$t('folderName') } }).then(({ canceled, result: name }) => { if (canceled) return; - this.$root.api('drive/folders/create', { + os.api('drive/folders/create', { name: name, parentId: this.folder ? this.folder.id : undefined }).then(folder => { @@ -340,7 +334,7 @@ export default Vue.extend({ }, renameFolder(folder) { - this.$root.dialog({ + os.dialog({ title: this.$t('renameFolder'), input: { placeholder: this.$t('inputNewFolderName'), @@ -348,7 +342,7 @@ export default Vue.extend({ } }).then(({ canceled, result: name }) => { if (canceled) return; - this.$root.api('drive/folders/update', { + os.api('drive/folders/update', { folderId: folder.id, name: name }).then(folder => { @@ -359,7 +353,7 @@ export default Vue.extend({ }, deleteFolder(folder) { - this.$root.api('drive/folders/delete', { + os.api('drive/folders/delete', { folderId: folder.id }).then(() => { // 削除時に親フォルダに移動 @@ -367,14 +361,14 @@ export default Vue.extend({ }).catch(err => { switch(err.id) { case 'b0fc8a17-963c-405d-bfbc-859a487295e1': - this.$root.dialog({ + os.dialog({ type: 'error', title: this.$t('unableToDelete'), text: this.$t('hasChildFilesOrFolders') }); break; default: - this.$root.dialog({ + os.dialog({ type: 'error', text: this.$t('unableToDelete') }); @@ -390,7 +384,9 @@ export default Vue.extend({ upload(file, folder) { if (folder && typeof folder == 'object') folder = folder.id; - (this.$refs.uploader as any).upload(file, folder); + os.upload(file, folder).then(res => { + this.addFile(res, true); + }); }, chooseFile(file) { @@ -441,7 +437,7 @@ export default Vue.extend({ this.fetching = true; - this.$root.api('drive/folders/show', { + os.api('drive/folders/show', { folderId: target }).then(folder => { this.folder = folder; @@ -465,7 +461,7 @@ export default Vue.extend({ if (this.folders.some(f => f.id == folder.id)) { const exist = this.folders.map(f => f.id).indexOf(folder.id); - Vue.set(this.folders, exist, folder); + this.folders[exist] = folder; return; } @@ -482,7 +478,7 @@ export default Vue.extend({ 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); + this.files[exist] = file; return; } @@ -543,7 +539,7 @@ export default Vue.extend({ const filesMax = 30; // フォルダ一覧取得 - this.$root.api('drive/folders', { + os.api('drive/folders', { folderId: this.folder ? this.folder.id : null, limit: foldersMax + 1 }).then(folders => { @@ -556,7 +552,7 @@ export default Vue.extend({ }); // ファイル一覧取得 - this.$root.api('drive/files', { + os.api('drive/files', { folderId: this.folder ? this.folder.id : null, type: this.type, limit: filesMax + 1 @@ -587,7 +583,7 @@ export default Vue.extend({ const max = 30; // ファイル一覧取得 - this.$root.api('drive/files', { + os.api('drive/files', { folderId: this.folder ? this.folder.id : null, type: this.type, untilId: this.files[this.files.length - 1].id, @@ -602,7 +598,41 @@ export default Vue.extend({ for (const x of files) this.appendFile(x); this.fetching = false; }); - } + }, + + getMenu() { + return [{ + text: this.$t('addFile'), + type: 'label' + }, { + text: this.$t('upload'), + icon: faUpload, + action: () => { this.selectLocalFile(); } + }, { + text: this.$t('fromUrl'), + icon: faLink, + action: () => { this.urlUpload(); } + }, null, { + text: this.folder ? this.folder.name : this.$t('drive'), + type: 'label' + }, this.folder ? { + text: this.$t('renameFolder'), + icon: faICursor, + action: () => { this.renameFolder(this.folder); } + } : undefined, this.folder ? { + text: this.$t('deleteFolder'), + icon: faTrashAlt, + action: () => { this.deleteFolder(this.folder); } + } : undefined, { + text: this.$t('createFolder'), + icon: faFolderPlus, + action: () => { this.createFolder(); } + }]; + }, + + onContextmenu(e) { + os.contextMenu(this.getMenu(), e); + }, } }); </script> @@ -613,6 +643,8 @@ export default Vue.extend({ display: block; z-index: 2; width: 100%; + padding: 0 8px; + box-sizing: border-box; overflow: auto; font-size: 0.9em; box-shadow: 0 1px 0 var(--divider); @@ -666,7 +698,6 @@ export default Vue.extend({ } > .main { - padding: 8px 0; overflow: auto; &, * { @@ -734,11 +765,6 @@ export default Vue.extend({ pointer-events: none; } - > .mk-uploader { - height: 100px; - padding: 16px; - } - > input { display: none; } diff --git a/src/client/components/emoji-picker.vue b/src/client/components/emoji-picker.vue index 7871b438c9..e79f3c9292 100644 --- a/src/client/components/emoji-picker.vue +++ b/src/client/components/emoji-picker.vue @@ -1,6 +1,6 @@ <template> -<x-popup :source="source" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }"> - <div class="omfetrab"> +<MkModal ref="modal" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')"> + <div class="omfetrab _popup"> <header> <button v-for="(category, i) in categories" class="_button" @@ -8,26 +8,26 @@ :class="{ active: category.isActive }" :key="i" > - <fa :icon="category.icon" fixed-width/> + <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('recentUsed') }}</header> + <header class="category"><Fa :icon="faHistory" fixed-width/> {{ $t('recentUsed') }}</header> <div class="list"> - <button v-for="(emoji, i) in ($store.state.device.recentEmojis || [])" + <button v-for="emoji in ($store.state.device.recentEmojis || [])" class="_button" :title="emoji.name" @click="chosen(emoji)" - :key="i" + :key="emoji" > - <mk-emoji v-if="emoji.char != null" :emoji="emoji.char"/> + <MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/> <img v-else :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> </button> </div> - <header class="category"><fa :icon="faAsterisk" fixed-width/> {{ $t('customEmojis') }}</header> + <header class="category"><Fa :icon="faAsterisk" fixed-width/> {{ $t('customEmojis') }}</header> </template> <template v-if="categories.find(x => x.isActive).name"> @@ -38,7 +38,7 @@ @click="chosen(emoji)" :key="emoji.name" > - <mk-emoji :emoji="emoji.char"/> + <MkEmoji :emoji="emoji.char"/> </button> </div> </template> @@ -59,29 +59,31 @@ </template> </div> </div> -</x-popup> +</MkModal> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { emojilist } from '../../misc/emojilist'; -import { getStaticImageUrl } from '../scripts/get-static-image-url'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; import { faAsterisk, faLeaf, faUtensils, faFutbol, faCity, faDice, faGlobe, faHistory, faUser } 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'; +import MkModal from '@/components/ui/modal.vue'; -export default Vue.extend({ +export default defineComponent({ components: { - XPopup, + MkModal, }, props: { - source: { - required: true + src: { + required: false }, }, + emits: ['done', 'closed'], + data() { return { emojilist, @@ -162,12 +164,9 @@ export default Vue.extend({ 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)); + this.$emit('done', getKey(emoji)); + this.$refs.modal.close(); }, - - close() { - this.$refs.popup.close(); - } } }); </script> diff --git a/src/client/components/emoji.vue b/src/client/components/emoji.vue index 277ba1a07d..ba65ada8ac 100644 --- a/src/client/components/emoji.vue +++ b/src/client/components/emoji.vue @@ -6,11 +6,12 @@ </template> <script lang="ts"> -import Vue from 'vue'; -import { getStaticImageUrl } from '../scripts/get-static-image-url'; +import { defineComponent } from 'vue'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; import { twemojiSvgBase } from '../../misc/twemoji-base'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { name: { type: String, diff --git a/src/client/components/error.vue b/src/client/components/error.vue index 90efa700b2..cac24efc8c 100644 --- a/src/client/components/error.vue +++ b/src/client/components/error.vue @@ -1,19 +1,19 @@ <template> <transition :name="$store.state.device.animation ? 'zoom' : ''" appear> - <div class="mjndxjcg _panel"> + <div class="mjndxjcg"> <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> - <p><fa :icon="faExclamationTriangle"/> {{ $t('error') }}</p> - <mk-button @click="() => $emit('retry')" class="button">{{ $t('retry') }}</mk-button> + <p><Fa :icon="faExclamationTriangle"/> {{ $t('somethingHappened') }}</p> + <MkButton @click="() => $emit('retry')" class="button">{{ $t('retry') }}</MkButton> </div> </transition> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; import MkButton from './ui/button.vue'; -export default Vue.extend({ +export default defineComponent({ components: { MkButton, }, diff --git a/src/client/components/file-type-icon.vue b/src/client/components/file-type-icon.vue index 8492567ad7..ec29d5c882 100644 --- a/src/client/components/file-type-icon.vue +++ b/src/client/components/file-type-icon.vue @@ -1,14 +1,15 @@ <template> <span class="mk-file-type-icon"> - <template v-if="kind == 'image'"><fa :icon="faFileImage"/></template> + <template v-if="kind == 'image'"><Fa :icon="faFileImage"/></template> </span> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faFileImage } from '@fortawesome/free-solid-svg-icons'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { type: { type: String, diff --git a/src/client/components/follow-button.vue b/src/client/components/follow-button.vue index 7967c0e159..3b8f7454c2 100644 --- a/src/client/components/follow-button.vue +++ b/src/client/components/follow-button.vue @@ -7,32 +7,33 @@ > <template v-if="!wait"> <template v-if="hasPendingFollowRequestFromYou && user.isLocked"> - <span v-if="full">{{ $t('followRequestPending') }}</span><fa :icon="faHourglassHalf"/> + <span v-if="full">{{ $t('followRequestPending') }}</span><Fa :icon="faHourglassHalf"/> </template> <template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked"> <!-- つまりリモートフォローの場合。 --> - <span v-if="full">{{ $t('processing') }}</span><fa :icon="faSpinner" pulse/> + <span v-if="full">{{ $t('processing') }}</span><Fa :icon="faSpinner" pulse/> </template> <template v-else-if="isFollowing"> - <span v-if="full">{{ $t('unfollow') }}</span><fa :icon="faMinus"/> + <span v-if="full">{{ $t('unfollow') }}</span><Fa :icon="faMinus"/> </template> <template v-else-if="!isFollowing && user.isLocked"> - <span v-if="full">{{ $t('followRequest') }}</span><fa :icon="faPlus"/> + <span v-if="full">{{ $t('followRequest') }}</span><Fa :icon="faPlus"/> </template> <template v-else-if="!isFollowing && !user.isLocked"> - <span v-if="full">{{ $t('follow') }}</span><fa :icon="faPlus"/> + <span v-if="full">{{ $t('follow') }}</span><Fa :icon="faPlus"/> </template> </template> <template v-else> - <span v-if="full">{{ $t('processing') }}</span><fa :icon="faSpinner" pulse fixed-width/> + <span v-if="full">{{ $t('processing') }}</span><Fa :icon="faSpinner" pulse fixed-width/> </template> </button> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faSpinner, faPlus, faMinus, faHourglassHalf } from '@fortawesome/free-solid-svg-icons'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { user: { type: Object, @@ -58,7 +59,7 @@ export default Vue.extend({ created() { // 渡されたユーザー情報が不完全な場合 if (this.user.isFollowing == null) { - this.$root.api('users/show', { + os.api('users/show', { userId: this.user.id }).then(u => { this.isFollowing = u.isFollowing; @@ -68,13 +69,13 @@ export default Vue.extend({ }, mounted() { - this.connection = this.$root.stream.useSharedConnection('main'); + this.connection = os.stream.useSharedConnection('main'); this.connection.on('follow', this.onFollowChange); this.connection.on('unfollow', this.onFollowChange); }, - beforeDestroy() { + beforeUnmount() { this.connection.dispose(); }, @@ -91,7 +92,7 @@ export default Vue.extend({ try { if (this.isFollowing) { - const { canceled } = await this.$root.dialog({ + const { canceled } = await os.dialog({ type: 'warning', text: this.$t('unfollowConfirm', { name: this.user.name || this.user.username }), showCancelButton: true @@ -99,21 +100,21 @@ export default Vue.extend({ if (canceled) return; - await this.$root.api('following/delete', { + await os.api('following/delete', { userId: this.user.id }); } else { if (this.hasPendingFollowRequestFromYou) { - await this.$root.api('following/requests/cancel', { + await os.api('following/requests/cancel', { userId: this.user.id }); } else if (this.user.isLocked) { - await this.$root.api('following/create', { + await os.api('following/create', { userId: this.user.id }); this.hasPendingFollowRequestFromYou = true; } else { - await this.$root.api('following/create', { + await os.api('following/create', { userId: this.user.id }); this.hasPendingFollowRequestFromYou = true; diff --git a/src/client/components/form-window.vue b/src/client/components/form-dialog.vue index a656d64f84..2a067b67fa 100644 --- a/src/client/components/form-window.vue +++ b/src/client/components/form-dialog.vue @@ -1,41 +1,50 @@ <template> -<x-window ref="window" :width="400" :height="450" :no-padding="true" @closed="() => { $emit('closed'); destroyDom(); }" :with-ok-button="true" :ok-button-disabled="false" @ok="ok()" :can-close="false"> +<XModalWindow ref="dialog" + :width="400" + :can-close="false" + :with-ok-button="true" + :ok-button-disabled="false" + @click="cancel()" + @ok="ok()" + @close="cancel()" + @closed="$emit('closed')" +> <template #header> {{ title }} </template> - <div class="xkpnjxcv"> + <div class="xkpnjxcv _section"> <label v-for="item in Object.keys(form).filter(item => !form[item].hidden)" :key="item"> - <mk-input v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1"> + <MkInput v-if="form[item].type === 'number'" v-model:value="values[item]" type="number" :step="form[item].step || 1"> <span v-text="form[item].label || item"></span> <template v-if="form[item].description" #desc>{{ form[item].description }}</template> - </mk-input> - <mk-input v-else-if="form[item].type === 'string' && !item.multiline" v-model="values[item]" type="text"> + </MkInput> + <MkInput v-else-if="form[item].type === 'string' && !item.multiline" v-model:value="values[item]" type="text"> <span v-text="form[item].label || item"></span> <template v-if="form[item].description" #desc>{{ form[item].description }}</template> - </mk-input> - <mk-textarea v-else-if="form[item].type === 'string' && item.multiline" v-model="values[item]"> + </MkInput> + <MkTextarea v-else-if="form[item].type === 'string' && item.multiline" v-model:value="values[item]"> <span v-text="form[item].label || item"></span> <template v-if="form[item].description" #desc>{{ form[item].description }}</template> - </mk-textarea> - <mk-switch v-else-if="form[item].type === 'boolean'" v-model="values[item]"> + </MkTextarea> + <MkSwitch v-else-if="form[item].type === 'boolean'" v-model:value="values[item]"> <span v-text="form[item].label || item"></span> <template v-if="form[item].description" #desc>{{ form[item].description }}</template> - </mk-switch> + </MkSwitch> </label> </div> -</x-window> +</XModalWindow> </template> <script lang="ts"> -import Vue from 'vue'; -import XWindow from './window.vue'; +import { defineComponent } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; import MkInput from './ui/input.vue'; import MkTextarea from './ui/textarea.vue'; import MkSwitch from './ui/switch.vue'; -export default Vue.extend({ +export default defineComponent({ components: { - XWindow, + XModalWindow, MkInput, MkTextarea, MkSwitch, @@ -52,6 +61,8 @@ export default Vue.extend({ }, }, + emits: ['done'], + data() { return { values: {} @@ -60,15 +71,24 @@ export default Vue.extend({ created() { for (const item in this.form) { - Vue.set(this.values, item, this.form[item].hasOwnProperty('default') ? this.form[item].default : null); + this.values[item] = this.form[item].hasOwnProperty('default') ? this.form[item].default : null; } }, methods: { ok() { - this.$emit('ok', this.values); - this.$refs.window.close(); + this.$emit('done', { + result: this.values + }); + this.$refs.dialog.close(); }, + + cancel() { + this.$emit('done', { + canceled: true + }); + this.$refs.dialog.close(); + } } }); </script> @@ -77,7 +97,10 @@ export default Vue.extend({ .xkpnjxcv { > label { display: block; - padding: 16px 24px; + + &:not(:last-child) { + margin-bottom: 32px; + } } } </style> diff --git a/src/client/components/formula-core.vue b/src/client/components/formula-core.vue index 45b27f9026..29c049297e 100644 --- a/src/client/components/formula-core.vue +++ b/src/client/components/formula-core.vue @@ -5,9 +5,10 @@ </template> <script lang="ts"> -import Vue from 'vue'; -import * as katex from 'katex'; -export default Vue.extend({ +import { defineComponent } from 'vue'; +import * as katex from 'katex';import * as os from '@/os'; + +export default defineComponent({ props: { formula: { type: String, diff --git a/src/client/components/formula.vue b/src/client/components/formula.vue index 4aaad1bf3e..fbb40bace7 100644 --- a/src/client/components/formula.vue +++ b/src/client/components/formula.vue @@ -1,12 +1,13 @@ <template> -<x-formula :formula="formula" :block="block" /> +<XFormula :formula="formula" :block="block" /> </template> <script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ +import { defineComponent, defineAsyncComponent } from 'vue';import * as os from '@/os'; + +export default defineComponent({ components: { - XFormula: () => import('./formula-core.vue').then(m => m.default) + XFormula: defineAsyncComponent(() => import('./formula-core.vue')) }, props: { formula: { diff --git a/src/client/components/google.vue b/src/client/components/google.vue index 630e8b83bc..bec670ac5f 100644 --- a/src/client/components/google.vue +++ b/src/client/components/google.vue @@ -1,15 +1,16 @@ <template> <div class="mk-google"> <input type="search" v-model="query" :placeholder="q"> - <button @click="search"><fa :icon="faSearch"/> {{ $t('search') }}</button> + <button @click="search"><Fa :icon="faSearch"/> {{ $t('search') }}</button> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faSearch } from '@fortawesome/free-solid-svg-icons'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: ['q'], data() { return { @@ -23,7 +24,7 @@ export default Vue.extend({ methods: { search() { const engine = this.$store.state.settings.webSearchEngine || - 'https://www.google.com/?#q={{query}}'; + 'https://www.google.com/search?q={{query}}'; const url = engine.replace('{{query}}', this.query) window.open(url, '_blank'); } diff --git a/src/client/components/header-clock.vue b/src/client/components/header-clock.vue index 696fd4eb67..6be8effbb7 100644 --- a/src/client/components/header-clock.vue +++ b/src/client/components/header-clock.vue @@ -8,16 +8,17 @@ </time> </div> <div class="content _panel _ghost"> - <mk-clock/> + <MkClock/> </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import MkClock from './analog-clock.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkClock }, @@ -48,7 +49,7 @@ export default Vue.extend({ this.tick(); this.clock = setInterval(this.tick, 1000); }, - beforeDestroy() { + beforeUnmount() { clearInterval(this.clock); }, methods: { diff --git a/src/client/components/icon-dialog.vue b/src/client/components/icon-dialog.vue new file mode 100644 index 0000000000..e8eae3342f --- /dev/null +++ b/src/client/components/icon-dialog.vue @@ -0,0 +1,73 @@ +<template> +<MkModal ref="modal" @click="type === 'success' ? done() : () => {}" @closed="$emit('closed')"> + <div class="iuyakobc" :class="type"> + <Fa class="icon" v-if="type === 'success'" :icon="faCheck"/> + <Fa class="icon" v-else-if="type === 'waiting'" :icon="faSpinner" pulse/> + </div> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faCheck, faSpinner } from '@fortawesome/free-solid-svg-icons'; +import MkModal from '@/components/ui/modal.vue'; + +export default defineComponent({ + components: { + MkModal, + }, + + props: { + type: { + required: true + }, + showing: { + required: true + } + }, + + emits: ['done', 'closed'], + + data() { + return { + faCheck, faSpinner, + }; + }, + + watch: { + showing() { + if (!this.showing) this.done(); + } + }, + + methods: { + done() { + this.$emit('done'); + this.$refs.modal.close(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.iuyakobc { + position: relative; + padding: 32px; + box-sizing: border-box; + text-align: center; + background: var(--panel); + border-radius: var(--radius); + width: initial; + font-size: 32px; + + &.success { + color: var(--accent); + } + + &.waiting { + > .icon { + opacity: 0.7; + } + } +} +</style> diff --git a/src/client/components/image-viewer.vue b/src/client/components/image-viewer.vue index c78112b988..adde74cb3a 100644 --- a/src/client/components/image-viewer.vue +++ b/src/client/components/image-viewer.vue @@ -1,16 +1,26 @@ <template> -<x-modal ref="modal" @closed="() => { $emit('closed'); destroyDom(); }"> - <img class="xubzgfga" ref="img" :src="image.url" :alt="image.name" :title="image.name" @click="close" tabindex="-1"/> -</x-modal> +<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')"> + <div class="xubzgfga"> + <header>{{ image.name }}</header> + <img :src="image.url" :alt="image.name" :title="image.name" @click="$refs.modal.close()"/> + <footer> + <span>{{ image.type }}</span> + <span>{{ bytes(image.size) }}</span> + <span v-if="image.properties?.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span> + </footer> + </div> +</MkModal> </template> <script lang="ts"> -import Vue from 'vue'; -import XModal from './modal.vue'; +import { defineComponent } from 'vue'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; +import MkModal from '@/components/ui/modal.vue'; -export default Vue.extend({ +export default defineComponent({ components: { - XModal, + MkModal, }, props: { @@ -20,32 +30,50 @@ export default Vue.extend({ }, }, - mounted() { - this.$nextTick(() => { - this.$refs.img.focus(); - }); - }, + emits: ['closed'], methods: { - close() { - this.$refs.modal.close(); - }, + bytes, + number, } }); </script> <style lang="scss" scoped> .xubzgfga { - 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; + max-width: 1024px; + + > header, + > footer { + display: inline-block; + padding: 6px 9px; + font-size: 90%; + background: rgba(0, 0, 0, 0.5); + border-radius: 6px; + color: #fff; + } + + > header { + margin-bottom: 8px; + opacity: 0.9; + } + + > img { + display: block; + max-width: 100%; + cursor: zoom-out; + image-orientation: from-image; + } + + > footer { + margin-top: 8px; + opacity: 0.8; + + > span + span { + margin-left: 0.5em; + padding-left: 0.5em; + border-left: solid 1px rgba(255, 255, 255, 0.5); + } + } } </style> diff --git a/src/client/components/img-with-blurhash.vue b/src/client/components/img-with-blurhash.vue index 6e6a2a8965..7606708e9b 100644 --- a/src/client/components/img-with-blurhash.vue +++ b/src/client/components/img-with-blurhash.vue @@ -1,15 +1,15 @@ <template> -<div class="xubzgfgb" :title="title"> +<div class="xubzgfgb" :class="{ cover }" :title="title"> <canvas ref="canvas" :width="size" :height="size" :title="title" v-if="!loaded"/> <img v-if="src" :src="src" :title="title" :alt="alt" @load="onLoad"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { decode } from 'blurhash'; -export default Vue.extend({ +export default defineComponent({ props: { src: { type: String, @@ -35,6 +35,11 @@ export default Vue.extend({ required: false, default: 64 }, + cover: { + type: Boolean, + required: false, + default: true, + } }, data() { @@ -49,6 +54,7 @@ export default Vue.extend({ methods: { draw() { + if (this.hash == null) return; const pixels = decode(this.hash, this.size, this.size); const ctx = (this.$refs.canvas as HTMLCanvasElement).getContext('2d'); const imageData = ctx!.createImageData(this.size, this.size); @@ -70,9 +76,23 @@ export default Vue.extend({ > canvas, > img { + display: block; width: 100%; height: 100%; + } + + > canvas { object-fit: cover; } + + > img { + object-fit: contain; + } + + &.cover { + > img { + object-fit: cover; + } + } } </style> diff --git a/src/client/components/index.ts b/src/client/components/index.ts index 87547599a9..6cc06e37c3 100644 --- a/src/client/components/index.ts +++ b/src/client/components/index.ts @@ -1,4 +1,4 @@ -import Vue from 'vue'; +import { App } from 'vue'; import mfm from './misskey-flavored-markdown.vue'; import acct from './acct.vue'; @@ -12,14 +12,16 @@ import loading from './loading.vue'; import error from './error.vue'; import streamIndicator from './stream-indicator.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('stream-indicator', streamIndicator); +export default function(app: App) { + app.component('Mfm', mfm); + app.component('MkAcct', acct); + app.component('MkAvatar', avatar); + app.component('MkEmoji', emoji); + app.component('MkUserName', userName); + app.component('MkEllipsis', ellipsis); + app.component('MkTime', time); + app.component('MkUrl', url); + app.component('MkLoading', loading); + app.component('MkError', error); + app.component('StreamIndicator', streamIndicator); +} diff --git a/src/client/components/instance-stats.vue b/src/client/components/instance-stats.vue index 1f3d45d4d9..dcb3c75fa0 100644 --- a/src/client/components/instance-stats.vue +++ b/src/client/components/instance-stats.vue @@ -1,93 +1,93 @@ <template> -<div class="zbcjwnqg" v-size="{ max: [550, 1200] }"> +<div class="zbcjwnqg" v-size="{ max: [550, 1000] }"> <div class="stats" v-if="info"> <div class="_panel"> <div> - <b><fa :icon="faUser"/>{{ $t('users') }}</b> + <b><Fa :icon="faUser"/>{{ $t('users') }}</b> <small>{{ $t('local') }}</small> </div> <div> <dl class="total"> <dt>{{ $t('total') }}</dt> - <dd>{{ info.originalUsersCount | number }}</dd> + <dd>{{ number(info.originalUsersCount) }}</dd> </dl> <dl class="diff" :class="{ inc: usersLocalDoD > 0 }"> <dt>{{ $t('dayOverDayChanges') }}</dt> - <dd>{{ usersLocalDoD | number }}</dd> + <dd>{{ number(usersLocalDoD) }}</dd> </dl> <dl class="diff" :class="{ inc: usersLocalWoW > 0 }"> <dt>{{ $t('weekOverWeekChanges') }}</dt> - <dd>{{ usersLocalWoW | number }}</dd> + <dd>{{ number(usersLocalWoW) }}</dd> </dl> </div> </div> <div class="_panel"> <div> - <b><fa :icon="faUser"/>{{ $t('users') }}</b> + <b><Fa :icon="faUser"/>{{ $t('users') }}</b> <small>{{ $t('remote') }}</small> </div> <div> <dl class="total"> <dt>{{ $t('total') }}</dt> - <dd>{{ (info.usersCount - info.originalUsersCount) | number }}</dd> + <dd>{{ number((info.usersCount - info.originalUsersCount)) }}</dd> </dl> <dl class="diff" :class="{ inc: usersRemoteDoD > 0 }"> <dt>{{ $t('dayOverDayChanges') }}</dt> - <dd>{{ usersRemoteDoD | number }}</dd> + <dd>{{ number(usersRemoteDoD) }}</dd> </dl> <dl class="diff" :class="{ inc: usersRemoteWoW > 0 }"> <dt>{{ $t('weekOverWeekChanges') }}</dt> - <dd>{{ usersRemoteWoW | number }}</dd> + <dd>{{ number(usersRemoteWoW) }}</dd> </dl> </div> </div> <div class="_panel"> <div> - <b><fa :icon="faPencilAlt"/>{{ $t('notes') }}</b> + <b><Fa :icon="faPencilAlt"/>{{ $t('notes') }}</b> <small>{{ $t('local') }}</small> </div> <div> <dl class="total"> <dt>{{ $t('total') }}</dt> - <dd>{{ info.originalNotesCount | number }}</dd> + <dd>{{ number(info.originalNotesCount) }}</dd> </dl> <dl class="diff" :class="{ inc: notesLocalDoD > 0 }"> <dt>{{ $t('dayOverDayChanges') }}</dt> - <dd>{{ notesLocalDoD | number }}</dd> + <dd>{{ number(notesLocalDoD) }}</dd> </dl> <dl class="diff" :class="{ inc: notesLocalWoW > 0 }"> <dt>{{ $t('weekOverWeekChanges') }}</dt> - <dd>{{ notesLocalWoW | number }}</dd> + <dd>{{ number(notesLocalWoW) }}</dd> </dl> </div> </div> <div class="_panel"> <div> - <b><fa :icon="faPencilAlt"/>{{ $t('notes') }}</b> + <b><Fa :icon="faPencilAlt"/>{{ $t('notes') }}</b> <small>{{ $t('remote') }}</small> </div> <div> <dl class="total"> <dt>{{ $t('total') }}</dt> - <dd>{{ (info.notesCount - info.originalNotesCount) | number }}</dd> + <dd>{{ number((info.notesCount - info.originalNotesCount)) }}</dd> </dl> <dl class="diff" :class="{ inc: notesRemoteDoD > 0 }"> <dt>{{ $t('dayOverDayChanges') }}</dt> - <dd>{{ notesRemoteDoD | number }}</dd> + <dd>{{ number(notesRemoteDoD) }}</dd> </dl> <dl class="diff" :class="{ inc: notesRemoteWoW > 0 }"> <dt>{{ $t('weekOverWeekChanges') }}</dt> - <dd>{{ notesRemoteWoW | number }}</dd> + <dd>{{ number(notesRemoteWoW) }}</dd> </dl> </div> </div> </div> <section class="_card"> - <div class="_title" style="position: relative;"><fa :icon="faChartBar"/> {{ $t('statistics') }}<button @click="fetchChart" class="_button" style="position: absolute; right: 0; bottom: 0; top: 0; padding: inherit;"><fa :icon="faSync"/></button></div> + <div class="_title" style="position: relative;"><Fa :icon="faChartBar"/> {{ $t('statistics') }}<button @click="fetchChart" class="_button" style="position: absolute; right: 0; bottom: 0; top: 0; padding: inherit;"><Fa :icon="faSync"/></button></div> <div class="_content" style="margin-top: -8px;"> <div class="selects" style="display: flex;"> - <mk-select v-model="chartSrc" style="margin: 0; flex: 1;"> + <MkSelect v-model:value="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> @@ -109,11 +109,11 @@ <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;"> + </MkSelect> + <MkSelect v-model:value="chartSpan" style="margin: 0;"> <option value="hour">{{ $t('perHour') }}</option> <option value="day">{{ $t('perDay') }}</option> - </mk-select> + </MkSelect> </div> <canvas ref="chart"></canvas> </div> @@ -122,10 +122,11 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent, markRaw } from 'vue'; import { faChartBar, faUser, faPencilAlt, faSync } from '@fortawesome/free-solid-svg-icons'; import Chart from 'chart.js'; import MkSelect from './ui/select.vue'; +import number from '@/filters/number'; const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); const negate = arr => arr.map(x => -x); @@ -136,8 +137,9 @@ const alpha = (hex, a) => { const b = parseInt(result[3], 16); return `rgba(${r}, ${g}, ${b}, ${a})`; }; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkSelect }, @@ -216,7 +218,7 @@ export default Vue.extend({ }, async created() { - this.info = await this.$root.api('stats'); + this.info = await os.api('stats'); this.now = new Date(); @@ -226,17 +228,17 @@ export default Vue.extend({ methods: { async fetchChart() { const [perHour, perDay] = await Promise.all([Promise.all([ - this.$root.api('charts/federation', { limit: this.chartLimit, span: 'hour' }), - this.$root.api('charts/users', { limit: this.chartLimit, span: 'hour' }), - this.$root.api('charts/active-users', { limit: this.chartLimit, span: 'hour' }), - this.$root.api('charts/notes', { limit: this.chartLimit, span: 'hour' }), - this.$root.api('charts/drive', { limit: this.chartLimit, span: 'hour' }), + os.api('charts/federation', { limit: this.chartLimit, span: 'hour' }), + os.api('charts/users', { limit: this.chartLimit, span: 'hour' }), + os.api('charts/active-users', { limit: this.chartLimit, span: 'hour' }), + os.api('charts/notes', { limit: this.chartLimit, span: 'hour' }), + os.api('charts/drive', { limit: this.chartLimit, span: 'hour' }), ]), Promise.all([ - this.$root.api('charts/federation', { limit: this.chartLimit, span: 'day' }), - this.$root.api('charts/users', { limit: this.chartLimit, span: 'day' }), - this.$root.api('charts/active-users', { limit: this.chartLimit, span: 'day' }), - this.$root.api('charts/notes', { limit: this.chartLimit, span: 'day' }), - this.$root.api('charts/drive', { limit: this.chartLimit, span: 'day' }), + os.api('charts/federation', { limit: this.chartLimit, span: 'day' }), + os.api('charts/users', { limit: this.chartLimit, span: 'day' }), + os.api('charts/active-users', { limit: this.chartLimit, span: 'day' }), + os.api('charts/notes', { limit: this.chartLimit, span: 'day' }), + os.api('charts/drive', { limit: this.chartLimit, span: 'day' }), ])]); const chart = { @@ -279,7 +281,7 @@ export default Vue.extend({ const gridColor = this.$store.state.device.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - this.chartInstance = new Chart(this.$refs.chart, { + this.chartInstance = markRaw(new Chart(this.$refs.chart, { type: 'line', data: { labels: new Array(this.chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(), @@ -344,7 +346,7 @@ export default Vue.extend({ mode: 'index', } } - }); + })); }, getDate(ago: number) { @@ -622,13 +624,15 @@ export default Vue.extend({ }] }; }, + + number } }); </script> <style lang="scss" scoped> .zbcjwnqg { - &.max-width_1200px { + &.max-width_1000px { > .stats { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; diff --git a/src/client/components/link.vue b/src/client/components/link.vue index 7a364d0986..e0a7f43477 100644 --- a/src/client/components/link.vue +++ b/src/client/components/link.vue @@ -5,18 +5,18 @@ :title="url" > <slot></slot> - <fa :icon="faExternalLinkSquareAlt" v-if="target === '_blank'" class="icon"/> + <Fa :icon="faExternalLinkSquareAlt" v-if="target === '_blank'" class="icon"/> </component> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons'; -import { url as local } from '../config'; -import MkUrlPreview from './url-preview-popup.vue'; -import { isDeviceTouch } from '../scripts/is-device-touch'; +import { url as local } from '@/config'; +import { isDeviceTouch } from '@/scripts/is-device-touch'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { url: { type: String, @@ -36,29 +36,34 @@ export default Vue.extend({ target: self ? null : '_blank', showTimer: null, hideTimer: null, - preview: null, + checkTimer: null, + close: null, faExternalLinkSquareAlt }; }, methods: { - showPreview() { + async showPreview() { if (!document.body.contains(this.$el)) return; - if (this.preview) return; + if (this.close) return; - this.preview = new MkUrlPreview({ - parent: this, - propsData: { - url: this.url, - source: this.$el - } - }).$mount(); + const { dispose } = os.popup(await import('@/components/url-preview-popup.vue'), { + url: this.url, + source: this.$el + }); - document.body.appendChild(this.preview.$el); + this.close = () => { + dispose(); + }; + + this.checkTimer = setInterval(() => { + if (!document.body.contains(this.$el)) this.closePreview(); + }, 1000); }, closePreview() { - if (this.preview) { - this.preview.destroyDom(); - this.preview = null; + if (this.close) { + clearInterval(this.checkTimer); + this.close(); + this.close = null; } }, onMouseover() { diff --git a/src/client/components/loading.vue b/src/client/components/loading.vue index b819c692e8..b4693258b9 100644 --- a/src/client/components/loading.vue +++ b/src/client/components/loading.vue @@ -5,9 +5,10 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { inline: { type: Boolean, diff --git a/src/client/components/media-banner.vue b/src/client/components/media-banner.vue index 0f746d4340..dd95b66de2 100644 --- a/src/client/components/media-banner.vue +++ b/src/client/components/media-banner.vue @@ -1,7 +1,7 @@ <template> <div class="mk-media-banner"> <div class="sensitive" v-if="media.isSensitive && hide" @click="hide = false"> - <span class="icon"><fa :icon="faExclamationTriangle"/></span> + <span class="icon"><Fa :icon="faExclamationTriangle"/></span> <b>{{ $t('sensitive') }}</b> <span>{{ $t('clickToShow') }}</span> </div> @@ -19,17 +19,18 @@ :title="media.name" :download="media.name" > - <span class="icon"><fa icon="download"/></span> + <span class="icon"><Fa icon="download"/></span> <b>{{ media.name }}</b> </a> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { media: { type: Object, diff --git a/src/client/components/media-image.vue b/src/client/components/media-image.vue index 7c4ac324cb..64e3efab31 100644 --- a/src/client/components/media-image.vue +++ b/src/client/components/media-image.vue @@ -1,34 +1,36 @@ <template> <div class="qjewsnkg" v-if="hide" @click="hide = false"> - <img-with-blurhash class="bg" :hash="image.blurhash" :title="image.name"/> + <ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.name"/> <div class="text"> <div> - <b><fa :icon="faExclamationTriangle"/> {{ $t('sensitive') }}</b> + <b><Fa :icon="faExclamationTriangle"/> {{ $t('sensitive') }}</b> <span>{{ $t('clickToShow') }}</span> </div> </div> </div> -<div class="gqnyydlz" v-else> - <i><fa :icon="faEyeSlash" @click="hide = true"/></i> +<div class="gqnyydlz" :style="{ background: color }" v-else> + <i><Fa :icon="faEyeSlash" @click="hide = true"/></i> <a :href="image.url" :title="image.name" @click.prevent="onClick" > - <img-with-blurhash :hash="image.blurhash" :src="url" :alt="image.name" :title="image.name"/> + <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.name" :title="image.name" :cover="false"/> <div class="gif" v-if="image.type === 'image/gif'">GIF</div> </a> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faExclamationTriangle, faEyeSlash } from '@fortawesome/free-solid-svg-icons'; -import { getStaticImageUrl } from '../scripts/get-static-image-url'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash'; import ImageViewer from './image-viewer.vue'; import ImgWithBlurhash from './img-with-blurhash.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { ImgWithBlurhash }, @@ -44,8 +46,8 @@ export default Vue.extend({ data() { return { hide: true, - faExclamationTriangle, - faEyeSlash + color: null, + faExclamationTriangle, faEyeSlash, }; }, computed: { @@ -67,6 +69,9 @@ export default Vue.extend({ // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする this.$watch('image', () => { this.hide = this.image.isSensitive && !this.$store.state.device.alwaysShowNsfw; + if (this.image.blurhash) { + this.color = extractAvgColorFromBlurhash(this.image.blurhash); + } }, { deep: true, immediate: true, @@ -77,12 +82,9 @@ export default Vue.extend({ if (this.$store.state.device.imageNewTab) { window.open(this.image.url, '_blank'); } else { - const viewer = this.$root.new(ImageViewer, { + os.popup(ImageViewer, { image: this.image - }); - this.$once('hook:beforeDestroy', () => { - viewer.close(); - }); + }, {}, 'closed'); } } } @@ -123,6 +125,7 @@ export default Vue.extend({ .gqnyydlz { position: relative; + border: solid 1px var(--divider); > i { display: block; diff --git a/src/client/components/media-list.vue b/src/client/components/media-list.vue index fd0035f10c..ce266acd20 100644 --- a/src/client/components/media-list.vue +++ b/src/client/components/media-list.vue @@ -1,13 +1,11 @@ <template> <div class="mk-media-list"> - <template v-for="media in mediaList.filter(media => !previewable(media))"> - <x-banner :media="media" :key="media.id"/> - </template> + <XBanner v-for="media in mediaList.filter(media => !previewable(media))" :media="media" :key="media.id"/> <div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container" ref="gridOuter"> <div :data-count="mediaList.filter(media => previewable(media)).length" :style="gridInnerStyle"> <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"/> + <XVideo :video="media" :key="media.id" v-if="media.type.startsWith('video')"/> + <XImage :image="media" :key="media.id" v-else-if="media.type.startsWith('image')" :raw="raw"/> </template> </div> </div> @@ -15,12 +13,13 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import XBanner from './media-banner.vue'; import XImage from './media-image.vue'; import XVideo from './media-video.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XBanner, XImage, @@ -46,7 +45,7 @@ export default Vue.extend({ this.size(); window.addEventListener('resize', this.size); }, - beforeDestroy() { + beforeUnmount() { window.removeEventListener('resize', this.size); }, activated() { diff --git a/src/client/components/media-video.vue b/src/client/components/media-video.vue index a5e06bfaa9..21faddf73b 100644 --- a/src/client/components/media-video.vue +++ b/src/client/components/media-video.vue @@ -1,12 +1,12 @@ <template> <div class="icozogqfvdetwohsdglrbswgrejoxbdj" v-if="hide" @click="hide = false"> <div> - <b><fa :icon="faExclamationTriangle"/> {{ $t('sensitive') }}</b> + <b><Fa :icon="faExclamationTriangle"/> {{ $t('sensitive') }}</b> <span>{{ $t('clickToShow') }}</span> </div> </div> <div class="kkjnbbplepmiyuadieoenjgutgcmtsvu" v-else> - <i><fa :icon="faEyeSlash" @click="hide = true"/></i> + <i><Fa :icon="faEyeSlash" @click="hide = true"/></i> <a :href="video.url" rel="nofollow noopener" @@ -14,17 +14,18 @@ :style="imageStyle" :title="video.name" > - <fa :icon="faPlayCircle"/> + <Fa :icon="faPlayCircle"/> </a> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faPlayCircle } from '@fortawesome/free-regular-svg-icons'; import { faExclamationTriangle, faEyeSlash } from '@fortawesome/free-solid-svg-icons'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { video: { type: Object, diff --git a/src/client/components/mention.vue b/src/client/components/mention.vue index dd68aab146..50b43df07b 100644 --- a/src/client/components/mention.vue +++ b/src/client/components/mention.vue @@ -15,12 +15,13 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { toUnicode } from 'punycode'; -import { host as localHost } from '../config'; +import { host as localHost } from '@/config'; import { wellKnownServices } from '../../well-known-services'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { username: { type: String, diff --git a/src/client/components/menu.vue b/src/client/components/menu.vue deleted file mode 100644 index 74e9a29ccf..0000000000 --- a/src/client/components/menu.vue +++ /dev/null @@ -1,191 +0,0 @@ -<template> -<x-popup :source="source" :no-center="noCenter" :fixed="fixed" :width="width" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }" v-hotkey.global="keymap"> - <div class="rrevdjwt" :class="{ left: align === 'left' }" ref="items"> - <template v-for="(item, i) in items.filter(item => item !== undefined)"> - <div v-if="item === null" class="divider" :key="i"></div> - <span v-else-if="item.type === 'label'" class="label item" :key="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"> - <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"> - <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"> - <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"> - <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> - </div> -</x-popup> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faCircle } from '@fortawesome/free-solid-svg-icons'; -import XPopup from './popup.vue'; -import { focusPrev, focusNext } from '../scripts/focus'; - -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 - }, - viaKeyboard: { - type: Boolean, - required: false - }, - }, - data() { - return { - faCircle - }; - }, - computed: { - keymap(): any { - return { - 'up|k|shift+tab': this.focusUp, - 'down|j|tab': this.focusDown, - }; - }, - }, - mounted() { - if (this.viaKeyboard) { - this.$nextTick(() => { - focusNext(this.$refs.items.children[0], true); - }); - } - }, - methods: { - clicked(fn) { - fn(); - this.close(); - }, - close() { - this.$refs.popup.close(); - }, - focusUp() { - focusPrev(document.activeElement); - }, - focusDown() { - focusNext(document.activeElement); - } - } -}); -</script> - -<style lang="scss" scoped> -.rrevdjwt { - padding: 8px 0; - - &.left { - > .item { - text-align: left; - } - } - - > .item { - display: block; - position: relative; - padding: 8px 16px; - width: 100%; - box-sizing: border-box; - white-space: nowrap; - font-size: 0.9em; - line-height: 20px; - text-align: center; - overflow: hidden; - text-overflow: ellipsis; - - &:hover { - color: #fff; - background: var(--accent); - text-decoration: none; - } - - &:active { - color: #fff; - background: var(--accentDarken); - } - - &:not(:active):focus { - box-shadow: 0 0 0 2px var(--focus) inset; - } - - &.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(--indicator); - font-size: 12px; - animation: blink 1s infinite; - } - } - - > .divider { - margin: 8px 0; - height: 1px; - background: var(--divider); - } -} -</style> diff --git a/src/client/components/mfm.ts b/src/client/components/mfm.ts index 1dc8780389..791fd1b4e5 100644 --- a/src/client/components/mfm.ts +++ b/src/client/components/mfm.ts @@ -1,16 +1,18 @@ -import Vue, { VNode } from 'vue'; +import { VNode, defineComponent, h } from 'vue'; import { MfmForest } from '../../mfm/prelude'; import { parse, parsePlain } from '../../mfm/parse'; import MkUrl from './url.vue'; import MkLink from './link.vue'; import MkMention from './mention.vue'; +import MkEmoji from './emoji.vue'; 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 { host } from '@/config'; +import { RouterLink } from 'vue-router'; -export default Vue.component('misskey-flavored-markdown', { +export default defineComponent({ props: { text: { type: String, @@ -41,7 +43,7 @@ export default Vue.component('misskey-flavored-markdown', { }, }, - render(createElement) { + render() { if (this.text == null || this.text == '') return; const ast = (this.plain ? parsePlain : parse)(this.text); @@ -53,67 +55,49 @@ export default Vue.component('misskey-flavored-markdown', { if (!this.plain) { const x = text.split('\n') - .map(t => t == '' ? [createElement('br')] : [this._v(t), createElement('br')]); // NOTE: this._vはHACK SEE: https://github.com/syuilo/misskey/pull/6399#issuecomment-632820283 + .map(t => t == '' ? [h('br')] : [t, h('br')]); x[x.length - 1].pop(); return x; } else { - return [this._v(text.replace(/\n/g, ' '))]; + return [text.replace(/\n/g, ' ')]; } } case 'bold': { - return [createElement('b', genEl(token.children))]; + return [h('b', genEl(token.children))]; } case 'strike': { - return [createElement('del', genEl(token.children))]; + return [h('del', genEl(token.children))]; } case 'italic': { - return (createElement as any)('i', { - attrs: { - style: 'font-style: oblique;' - }, + return h('i', { + style: 'font-style: oblique;' }, genEl(token.children)); } case 'big': { - return (createElement as any)('strong', { - attrs: { - style: `display: inline-block; font-size: 150%;` - }, - directives: [this.$store.state.device.animatedMfm ? { - name: 'animate-css', - value: { classes: 'tada', iteration: 'infinite' } - }: {}] + return h('strong', { + style: `display: inline-block; font-size: 150%;` + (this.$store.state.device.animatedMfm ? 'animation: anime-tada 1s linear infinite both;' : ''), }, genEl(token.children)); } case 'small': { - return [createElement('small', { - attrs: { - style: 'opacity: 0.7;' - }, + return [h('small', { + style: 'opacity: 0.7;' }, genEl(token.children))]; } case 'center': { - return [createElement('div', { - attrs: { - style: 'text-align:center;' - } + return [h('div', { + style: 'text-align:center;' }, genEl(token.children))]; } case 'motion': { - return (createElement as any)('span', { - attrs: { - style: 'display: inline-block;' - }, - directives: [this.$store.state.device.animatedMfm ? { - name: 'animate-css', - value: { classes: 'rubberBand', iteration: 'infinite' } - } : {}] + return h('span', { + style: 'display: inline-block;' + (this.$store.state.device.animatedMfm ? 'animation: anime-rubberBand 1s linear infinite both;' : ''), }, genEl(token.children)); } @@ -123,163 +107,126 @@ export default Vue.component('misskey-flavored-markdown', { token.node.props.attr == 'alternate' ? 'alternate' : 'normal'; const style = this.$store.state.device.animatedMfm - ? `animation: spin 1.5s linear infinite; animation-direction: ${direction};` : ''; - return (createElement as any)('span', { - attrs: { - style: 'display: inline-block;' + style - }, + ? `animation: anime-spin 1.5s linear infinite; animation-direction: ${direction};` : ''; + return h('span', { + style: 'display: inline-block;' + style }, genEl(token.children)); } case 'jump': { - return (createElement as any)('span', { - attrs: { - style: this.$store.state.device.animatedMfm ? 'display: inline-block; animation: jump 0.75s linear infinite;' : 'display: inline-block;' - }, + return h('span', { + style: this.$store.state.device.animatedMfm ? 'display: inline-block; animation: anime-jump 0.75s linear infinite;' : 'display: inline-block;' }, genEl(token.children)); } case 'flip': { - return (createElement as any)('span', { - attrs: { - style: 'display: inline-block; transform: scaleX(-1);' - }, + return h('span', { + style: 'display: inline-block; transform: scaleX(-1);' }, genEl(token.children)); } case 'url': { - return [createElement(MkUrl, { + return [h(MkUrl, { key: Math.random(), - props: { - url: token.node.props.url, - rel: 'nofollow noopener', - }, + url: token.node.props.url, + rel: 'nofollow noopener', })]; } case 'link': { - return [createElement(MkLink, { + return [h(MkLink, { key: Math.random(), - props: { - url: token.node.props.url, - rel: 'nofollow noopener', - }, + url: token.node.props.url, + rel: 'nofollow noopener', }, genEl(token.children))]; } case 'mention': { - return [createElement(MkMention, { + return [h(MkMention, { key: Math.random(), - props: { - host: (token.node.props.host == null && this.author && this.author.host != null ? this.author.host : token.node.props.host) || host, - username: token.node.props.username - } + host: (token.node.props.host == null && this.author && this.author.host != null ? this.author.host : token.node.props.host) || host, + username: token.node.props.username })]; } case 'hashtag': { - return [createElement('router-link', { + return [h(RouterLink, { key: Math.random(), - attrs: { - to: this.isNote ? `/tags/${encodeURIComponent(token.node.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.node.props.hashtag)}`, - style: 'color:var(--hashtag);' - } + to: this.isNote ? `/tags/${encodeURIComponent(token.node.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.node.props.hashtag)}`, + style: 'color:var(--hashtag);' }, `#${token.node.props.hashtag}`)]; } case 'blockCode': { - return [createElement(MkCode, { + return [h(MkCode, { key: Math.random(), - props: { - code: token.node.props.code, - lang: token.node.props.lang, - } + code: token.node.props.code, + lang: token.node.props.lang, })]; } case 'inlineCode': { - return [createElement(MkCode, { + return [h(MkCode, { key: Math.random(), - props: { - code: token.node.props.code, - lang: token.node.props.lang, - inline: true - } + code: token.node.props.code, + lang: token.node.props.lang, + inline: true })]; } case 'quote': { - if (this.shouldBreak) { - return [createElement('div', { - attrs: { - class: 'quote' - } + if (!this.nowrap) { + return [h('div', { + class: 'quote' }, genEl(token.children))]; } else { - return [createElement('span', { - attrs: { - class: 'quote' - } + return [h('span', { + class: 'quote' }, genEl(token.children))]; } } case 'title': { - return [createElement('div', { - attrs: { - class: 'title' - } + return [h('div', { + class: 'title' }, genEl(token.children))]; } case 'emoji': { - return [createElement('mk-emoji', { + return [h(MkEmoji, { key: Math.random(), - attrs: { - emoji: token.node.props.emoji, - name: token.node.props.name - }, - props: { - customEmojis: this.customEmojis, - normal: this.plain - } + emoji: token.node.props.emoji, + name: token.node.props.name, + customEmojis: this.customEmojis, + normal: this.plain })]; } case 'mathInline': { - //const MkFormula = () => import('./formula.vue').then(m => m.default); - return [createElement(MkFormula, { + return [h(MkFormula, { key: Math.random(), - props: { - formula: token.node.props.formula, - block: false - } + formula: token.node.props.formula, + block: false })]; } case 'mathBlock': { - //const MkFormula = () => import('./formula.vue').then(m => m.default); - return [createElement(MkFormula, { + return [h(MkFormula, { key: Math.random(), - props: { - formula: token.node.props.formula, - block: true - } + formula: token.node.props.formula, + block: true })]; } case 'search': { - //const MkGoogle = () => import('./google.vue').then(m => m.default); - return [createElement(MkGoogle, { + return [h(MkGoogle, { key: Math.random(), - props: { - q: token.node.props.query - } + q: token.node.props.query })]; } default: { - console.log('unrecognized ast type:', token.node.type); + console.error('unrecognized ast type:', token.node.type); return []; } @@ -287,6 +234,6 @@ export default Vue.component('misskey-flavored-markdown', { })); // Parse ast to DOM - return createElement('span', genEl(ast)); + return h('span', genEl(ast)); } }); diff --git a/src/client/components/mini-chart.vue b/src/client/components/mini-chart.vue index 5c4f74b6b4..2eb9ae8cbe 100644 --- a/src/client/components/mini-chart.vue +++ b/src/client/components/mini-chart.vue @@ -30,10 +30,11 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { v4 as uuid } from 'uuid'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { src: { type: Array, @@ -64,7 +65,7 @@ export default Vue.extend({ // Vueが何故かWatchを発動させない場合があるので this.clock = setInterval(this.draw, 1000); }, - beforeDestroy() { + beforeUnmount() { clearInterval(this.clock); }, methods: { diff --git a/src/client/components/misskey-flavored-markdown.vue b/src/client/components/misskey-flavored-markdown.vue index 7425f1f7be..030507ead2 100644 --- a/src/client/components/misskey-flavored-markdown.vue +++ b/src/client/components/misskey-flavored-markdown.vue @@ -3,10 +3,10 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import MfmCore from './mfm'; -export default Vue.extend({ +export default defineComponent({ components: { MfmCore } @@ -24,7 +24,7 @@ export default Vue.extend({ text-overflow: ellipsis; } - ::v-deep .quote { + ::v-deep(.quote) { display: block; margin: 8px; padding: 6px 0 6px 12px; @@ -33,15 +33,15 @@ export default Vue.extend({ opacity: 0.7; } - ::v-deep pre { + ::v-deep(pre) { font-size: 0.8em; } - ::v-deep > code { + > ::v-deep(code) { word-break: break-all; } - ::v-deep .title { + ::v-deep(.title) { text-align: center; border-bottom: solid 1px var(--divider); } diff --git a/src/client/components/modal.vue b/src/client/components/modal.vue deleted file mode 100644 index 6ae46d451e..0000000000 --- a/src/client/components/modal.vue +++ /dev/null @@ -1,90 +0,0 @@ -<template> -<div class="mk-modal" v-hotkey.global="keymap"> - <transition :name="$store.state.device.animation ? 'bg-fade' : ''" appear> - <div class="bg _modalBg" ref="bg" v-if="show" @click="canClose ? close() : () => {}"></div> - </transition> - <transition :name="$store.state.device.animation ? 'modal' : ''" appear @after-leave="() => { $emit('closed'); destroyDom(); }"> - <div class="content" ref="content" v-if="show" @click.self="canClose ? close() : () => {}"><slot></slot></div> - </transition> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: { - canClose: { - type: Boolean, - required: false, - default: true, - }, - }, - data() { - return { - show: true, - }; - }, - computed: { - keymap(): any { - return { - 'esc': this.close, - }; - }, - }, - 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 { - z-index: 10000; - } - - > .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 index 039287818f..3be0ba38fe 100644 --- a/src/client/components/note-header.vue +++ b/src/client/components/note-header.vue @@ -1,33 +1,36 @@ <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 class="name" :to="userPage(note.user)" v-user-preview="note.user.id"> + <MkUserName :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> - <span class="admin" v-if="note.user.isAdmin"><fa :icon="faBookmark"/></span> - <span class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><fa :icon="farBookmark"/></span> + <span class="username"><MkAcct :user="note.user"/></span> + <span class="admin" v-if="note.user.isAdmin"><Fa :icon="faBookmark"/></span> + <span class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><Fa :icon="farBookmark"/></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"/> + <span class="mobile" v-if="note.viaMobile"><Fa :icon="faMobileAlt"/></span> + <router-link class="created-at" :to="notePage(note)"> + <MkTime :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"/> + <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> - <span class="localOnly" v-if="note.localOnly"><fa :icon="faBiohazard"/></span> + <span class="localOnly" v-if="note.localOnly"><Fa :icon="faBiohazard"/></span> </div> </header> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faHome, faUnlock, faEnvelope, faMobileAlt, faBookmark, faBiohazard } from '@fortawesome/free-solid-svg-icons'; import { faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons'; +import notePage from '../filters/note'; +import { userPage } from '../filters/user'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { note: { type: Object, @@ -39,6 +42,11 @@ export default Vue.extend({ return { faHome, faUnlock, faEnvelope, faMobileAlt, faBookmark, farBookmark, faBiohazard }; + }, + + methods: { + notePage, + userPage } }); </script> diff --git a/src/client/components/note-preview.vue b/src/client/components/note-preview.vue index 14314889a3..4ea97d17ee 100644 --- a/src/client/components/note-preview.vue +++ b/src/client/components/note-preview.vue @@ -1,15 +1,15 @@ <template> <div class="yohlumlk"> - <mk-avatar class="avatar" :user="note.user"/> + <MkAvatar class="avatar" :user="note.user"/> <div class="main"> - <x-note-header class="header" :note="note" :mini="true"/> + <XNoteHeader class="header" :note="note" :mini="true"/> <div class="body"> <p v-if="note.cw != null" class="cw"> <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> - <x-cw-button v-model="showContent" :note="note"/> + <XCwButton v-model:value="showContent" :note="note"/> </p> <div class="content" v-show="note.cw == null || showContent"> - <x-sub-note-content class="text" :note="note"/> + <XSubNote-content class="text" :note="note"/> </div> </div> </div> @@ -17,12 +17,13 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import XNoteHeader from './note-header.vue'; import XSubNoteContent from './sub-note-content.vue'; import XCwButton from './cw-button.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XNoteHeader, XSubNoteContent, diff --git a/src/client/components/note.sub.vue b/src/client/components/note.sub.vue index 329fb62d53..2ce045a34e 100644 --- a/src/client/components/note.sub.vue +++ b/src/client/components/note.sub.vue @@ -1,32 +1,33 @@ <template> <div class="wrpstxzv" :class="{ children }" v-size="{ max: [450] }"> <div class="main"> - <mk-avatar class="avatar" :user="note.user"/> + <MkAvatar class="avatar" :user="note.user"/> <div class="body"> - <x-note-header class="header" :note="note" :mini="true"/> + <XNoteHeader class="header" :note="note" :mini="true"/> <div class="body"> <p v-if="note.cw != null" class="cw"> - <mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis" /> - <x-cw-button v-model="showContent" :note="note"/> + <Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis" /> + <XCwButton v-model:value="showContent" :note="note"/> </p> <div class="content" v-show="note.cw == null || showContent"> - <x-sub-note-content class="text" :note="note"/> + <XSubNote-content class="text" :note="note"/> </div> </div> </div> </div> - <x-sub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :children="true"/> + <XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :children="true"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import XNoteHeader from './note-header.vue'; import XSubNoteContent from './sub-note-content.vue'; import XCwButton from './cw-button.vue'; +import * as os from '@/os'; -export default Vue.extend({ - name: 'x-sub', +export default defineComponent({ + name: 'XSub', components: { XNoteHeader, @@ -65,7 +66,7 @@ export default Vue.extend({ created() { if (this.detail) { - this.$root.api('notes/children', { + os.api('notes/children', { noteId: this.note.id, limit: 5 }).then(replies => { diff --git a/src/client/components/note.vue b/src/client/components/note.vue index 31acd49003..b2cc5cce2c 100644 --- a/src/client/components/note.vue +++ b/src/client/components/note.vue @@ -8,95 +8,99 @@ v-hotkey="keymap" v-size="{ max: [500, 450, 350, 300] }" > - <x-sub v-for="note in conversation" class="reply-to-more" :key="note.id" :note="note"/> - <x-sub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> - <div class="info" v-if="pinned"><fa :icon="faThumbtack"/> {{ $t('pinnedNote') }}</div> - <div class="info" v-if="appearNote._prId_"><fa :icon="faBullhorn"/> {{ $t('promotion') }}<button class="_textButton hide" @click="readPromo()">{{ $t('hideThisNote') }} <fa :icon="faTimes"/></button></div> - <div class="info" v-if="appearNote._featuredId_"><fa :icon="faBolt"/> {{ $t('featured') }}</div> + <XSub v-for="note in conversation" class="reply-to-more" :key="note.id" :note="note"/> + <XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> + <div class="info" v-if="pinned"><Fa :icon="faThumbtack"/> {{ $t('pinnedNote') }}</div> + <div class="info" v-if="appearNote._prId_"><Fa :icon="faBullhorn"/> {{ $t('promotion') }}<button class="_textButton hide" @click="readPromo()">{{ $t('hideThisNote') }} <Fa :icon="faTimes"/></button></div> + <div class="info" v-if="appearNote._featuredId_"><Fa :icon="faBolt"/> {{ $t('featured') }}</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> + <MkAvatar class="avatar" :user="note.user"/> + <Fa :icon="faRetweet"/> + <i18n-t keypath="renotedBy" tag="span"> + <template #user> + <router-link class="name" :to="userPage(note.user)" v-user-preview="note.userId"> + <MkUserName :user="note.user"/> + </router-link> + </template> + </i18n-t> <div class="info"> <button class="_button time" @click="showRenoteMenu()" ref="renoteTime"> - <fa class="dropdownIcon" v-if="isMyRenote" :icon="faEllipsisH"/> - <mk-time :time="note.createdAt"/> + <Fa class="dropdownIcon" v-if="isMyRenote" :icon="faEllipsisH"/> + <MkTime :time="note.createdAt"/> </button> <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"/> + <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> - <span class="localOnly" v-if="note.localOnly"><fa :icon="faBiohazard"/></span> + <span class="localOnly" v-if="note.localOnly"><Fa :icon="faBiohazard"/></span> </div> </div> - <article class="article"> - <mk-avatar class="avatar" :user="appearNote.user"/> + <article class="article" @contextmenu="onContextmenu"> + <MkAvatar class="avatar" :user="appearNote.user"/> <div class="main"> - <x-note-header class="header" :note="appearNote" :mini="true"/> + <XNoteHeader class="header" :note="appearNote" :mini="true"/> <div class="body" ref="noteBody"> <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"/> + <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/> + <XCwButton v-model:value="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"/> + <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" :parent-element="noteBody"/> + <XMediaList :media-list="appearNote.files" :parent-element="noteBody"/> </div> - <x-poll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/> - <mk-url-preview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="detail" class="url-preview"/> - <div class="renote" v-if="appearNote.renote"><x-note-preview :note="appearNote.renote"/></div> + <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/> + <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="detail" class="url-preview"/> + <div class="renote" v-if="appearNote.renote"><XNotePreview :note="appearNote.renote"/></div> </div> - <router-link v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><fa :icon="faSatelliteDish"/> {{ appearNote.channel.name }}</router-link> + <router-link v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><Fa :icon="faSatelliteDish"/> {{ appearNote.channel.name }}</router-link> </div> <footer class="footer"> - <x-reactions-viewer :note="appearNote" ref="reactionsViewer"/> + <XReactionsViewer :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> + <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="canRenote" @click="renote()" class="button _button" ref="renoteButton"> - <fa :icon="faRetweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> + <Fa :icon="faRetweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> </button> <button v-else class="button _button"> - <fa :icon="faBan"/> + <Fa :icon="faBan"/> </button> <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton"> - <fa :icon="faPlus"/> + <Fa :icon="faPlus"/> </button> <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton"> - <fa :icon="faMinus"/> + <Fa :icon="faMinus"/> </button> <button class="button _button" @click="menu()" ref="menuButton"> - <fa :icon="faEllipsisH"/> + <Fa :icon="faEllipsisH"/> </button> </footer> </div> </article> - <x-sub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/> + <XSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/> </div> <div v-else class="_panel muted" @click="muted = false"> - <i18n path="userSaysSomething" tag="small"> - <router-link class="name" :to="appearNote.user | userPage" v-user-preview="appearNote.userId" place="name"> - <mk-user-name :user="appearNote.user"/> - </router-link> - </i18n> + <i18n-t keypath="userSaysSomething" tag="small"> + <template #name> + <router-link class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId"> + <MkUserName :user="appearNote.user"/> + </router-link> + </template> + </i18n-t> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { computed, defineAsyncComponent, defineComponent, markRaw, ref } from 'vue'; import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons'; import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; import { parse } from '../../mfm/parse'; @@ -108,21 +112,24 @@ import XReactionsViewer from './reactions-viewer.vue'; import XMediaList from './media-list.vue'; import XCwButton from './cw-button.vue'; import XPoll from './poll.vue'; -import MkUrlPreview from './url-preview.vue'; -import MkReactionPicker from './reaction-picker.vue'; -import pleaseLogin from '../scripts/please-login'; -import { focusPrev, focusNext } from '../scripts/focus'; -import { url } from '../config'; -import copyToClipboard from '../scripts/copy-to-clipboard'; -import { checkWordMute } from '../scripts/check-word-mute'; -import { utils } from '@syuilo/aiscript'; +import { pleaseLogin } from '@/scripts/please-login'; +import { focusPrev, focusNext } from '@/scripts/focus'; +import { url } from '@/config'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { checkWordMute } from '@/scripts/check-word-mute'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { noteActions, noteViewInterruptors } from '@/store'; -export default Vue.extend({ - model: { - prop: 'note', - event: 'updated' - }, +function markRawAll(...xs) { + for (const x of xs) { + markRaw(x); + } +} + +markRawAll(faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug, faSatelliteDish); +export default defineComponent({ components: { XSub, XNoteHeader, @@ -131,7 +138,7 @@ export default Vue.extend({ XMediaList, XCwButton, XPoll, - MkUrlPreview, + MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), }, inject: { @@ -157,6 +164,8 @@ export default Vue.extend({ }, }, + emits: ['update:note'], + data() { return { connection: null, @@ -171,6 +180,9 @@ export default Vue.extend({ }, computed: { + rs() { + return this.$store.state.settings.reactions; + }, keymap(): any { return { 'r': () => this.reply(true), @@ -184,16 +196,16 @@ export default Vue.extend({ '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]), + '1': () => this.reactDirectly(this.rs[0]), + '2': () => this.reactDirectly(this.rs[1]), + '3': () => this.reactDirectly(this.rs[2]), + '4': () => this.reactDirectly(this.rs[3]), + '5': () => this.reactDirectly(this.rs[4]), + '6': () => this.reactDirectly(this.rs[5]), + '7': () => this.reactDirectly(this.rs[6]), + '8': () => this.reactDirectly(this.rs[7]), + '9': () => this.reactDirectly(this.rs[8]), + '0': () => this.reactDirectly(this.rs[9]), }; }, @@ -251,22 +263,22 @@ export default Vue.extend({ async created() { if (this.$store.getters.isSignedIn) { - this.connection = this.$root.stream; + this.connection = os.stream; } // plugin - if (this.$store.state.noteViewInterruptors.length > 0) { + if (noteViewInterruptors.length > 0) { let result = this.note; - for (const interruptor of this.$store.state.noteViewInterruptors) { - result = utils.valToJs(await interruptor.handler(JSON.parse(JSON.stringify(result)))); + for (const interruptor of noteViewInterruptors) { + result = await interruptor.handler(JSON.parse(JSON.stringify(result))); } - this.$emit('updated', Object.freeze(result)); + this.$emit('update:note', Object.freeze(result)); } this.muted = await checkWordMute(this.appearNote, this.$store.state.i, this.$store.state.settings.mutedWords); if (this.detail) { - this.$root.api('notes/children', { + os.api('notes/children', { noteId: this.appearNote.id, limit: 30 }).then(replies => { @@ -274,7 +286,7 @@ export default Vue.extend({ }); if (this.appearNote.replyId) { - this.$root.api('notes/conversation', { + os.api('notes/conversation', { noteId: this.appearNote.replyId }).then(conversation => { this.conversation = conversation.reverse(); @@ -293,7 +305,7 @@ export default Vue.extend({ this.noteBody = this.$refs.noteBody; }, - beforeDestroy() { + beforeUnmount() { this.decapture(true); if (this.$store.getters.isSignedIn) { @@ -303,7 +315,7 @@ export default Vue.extend({ methods: { updateAppearNote(v) { - this.$emit('updated', Object.freeze(this.isRenote ? { + this.$emit('update:note', Object.freeze(this.isRenote ? { ...this.note, renote: { ...this.note.renote, @@ -316,7 +328,7 @@ export default Vue.extend({ }, readPromo() { - (this as any).$root.api('promo/read', { + os.api('promo/read', { noteId: this.appearNote.id }); this.isDeleted = true; @@ -439,8 +451,8 @@ export default Vue.extend({ }, reply(viaKeyboard = false) { - pleaseLogin(this.$root); - this.$root.post({ + pleaseLogin(); + os.post({ reply: this.appearNote, animation: !viaKeyboard, }, () => { @@ -449,57 +461,56 @@ export default Vue.extend({ }, renote(viaKeyboard = false) { - pleaseLogin(this.$root); + pleaseLogin(); this.blur(); - this.$root.menu({ - items: [{ - text: this.$t('renote'), - icon: faRetweet, - action: () => { - (this as any).$root.api('notes/create', { - renoteId: this.appearNote.id - }); - } - }, { - text: this.$t('quote'), - icon: faQuoteRight, - action: () => { - this.$root.post({ - renote: this.appearNote, - }); - } - }] - source: this.$refs.renoteButton, + os.modalMenu([{ + text: this.$t('renote'), + icon: faRetweet, + action: () => { + os.api('notes/create', { + renoteId: this.appearNote.id + }); + } + }, { + text: this.$t('quote'), + icon: faQuoteRight, + action: () => { + os.post({ + renote: this.appearNote, + }); + } + }], this.$refs.renoteButton, { viaKeyboard }); }, renoteDirectly() { - (this as any).$root.api('notes/create', { + os.api('notes/create', { renoteId: this.appearNote.id }); }, react(viaKeyboard = false) { - pleaseLogin(this.$root); + pleaseLogin(); this.blur(); - const picker = this.$root.new(MkReactionPicker, { - source: this.$refs.reactButton, + os.popup(defineAsyncComponent(() => import('@/components/reaction-picker.vue')), { 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); + src: this.$refs.reactButton, + }, { + done: reaction => { + if (reaction) { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + } + this.focus(); + }, + }, 'closed'); }, reactDirectly(reaction) { - this.$root.api('notes/reactions/create', { + os.api('notes/reactions/create', { noteId: this.appearNote.id, reaction: reaction }); @@ -508,81 +519,67 @@ export default Vue.extend({ undoReact(note) { const oldReaction = note.myReaction; if (!oldReaction) return; - this.$root.api('notes/reactions/delete', { + os.api('notes/reactions/delete', { noteId: note.id }); }, favorite() { - pleaseLogin(this.$root); - this.$root.api('notes/favorites/create', { + pleaseLogin(); + os.apiWithDialog('notes/favorites/create', { noteId: this.appearNote.id - }).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); }); }, del() { - this.$root.dialog({ + os.dialog({ type: 'warning', text: this.$t('noteDeleteConfirm'), showCancelButton: true }).then(({ canceled }) => { if (canceled) return; - this.$root.api('notes/delete', { + os.api('notes/delete', { noteId: this.appearNote.id }); }); }, delEdit() { - this.$root.dialog({ + os.dialog({ type: 'warning', text: this.$t('deleteAndEditConfirm'), showCancelButton: true }).then(({ canceled }) => { if (canceled) return; - this.$root.api('notes/delete', { + os.api('notes/delete', { noteId: this.appearNote.id }); - this.$root.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel }); + os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel }); }); }, toggleFavorite(favorite: boolean) { - this.$root.api(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { + os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { noteId: this.appearNote.id - }).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); }); }, toggleWatch(watch: boolean) { - this.$root.api(watch ? 'notes/watching/create' : 'notes/watching/delete', { + os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', { noteId: this.appearNote.id - }).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); }); }, - async menu(viaKeyboard = false) { + getMenu() { let menu; if (this.$store.getters.isSignedIn) { - const state = await this.$root.api('notes/state', { + const statePromise = os.api('notes/state', { noteId: this.appearNote.id }); + menu = [{ type: 'link', icon: faInfoCircle, @@ -604,7 +601,7 @@ export default Vue.extend({ } } : undefined, null, - state.isFavorited ? { + statePromise.then(state => state.isFavorited ? { icon: faStar, text: this.$t('unfavorite'), action: () => this.toggleFavorite(false) @@ -612,8 +609,8 @@ export default Vue.extend({ icon: faStar, text: this.$t('favorite'), action: () => this.toggleFavorite(true) - }, - this.appearNote.userId != this.$store.state.i.id ? state.isWatching ? { + }), + (this.appearNote.userId != this.$store.state.i.id) ? statePromise.then(state => state.isWatching ? { icon: faEyeSlash, text: this.$t('unwatch'), action: () => this.toggleWatch(false) @@ -621,7 +618,7 @@ export default Vue.extend({ icon: faEye, text: this.$t('watch'), action: () => this.toggleWatch(true) - } : undefined, + }) : undefined, this.appearNote.userId == this.$store.state.i.id ? (this.$store.state.i.pinnedNoteIds || []).includes(this.appearNote.id) ? { icon: faThumbtack, text: this.$t('unpin'), @@ -650,6 +647,7 @@ export default Vue.extend({ { icon: faTrashAlt, text: this.$t('delete'), + danger: true, action: this.del }] : [] @@ -674,8 +672,8 @@ export default Vue.extend({ .filter(x => x !== undefined); } - if (this.$store.state.noteActions.length > 0) { - menu = menu.concat([null, ...this.$store.state.noteActions.map(action => ({ + if (noteActions.length > 0) { + menu = menu.concat([null, ...noteActions.map(action => ({ icon: faPlug, text: action.title, action: () => { @@ -684,27 +682,39 @@ export default Vue.extend({ }))]); } - this.$root.menu({ - items: menu, - source: this.$refs.menuButton, + return menu; + }, + + onContextmenu(e) { + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(e.target)) return; + if (window.getSelection().toString() !== '') return; + os.contextMenu(this.getMenu(), e).then(this.focus); + }, + + menu(viaKeyboard = false) { + os.modalMenu(this.getMenu(), this.$refs.menuButton, { viaKeyboard }).then(this.focus); }, showRenoteMenu(viaKeyboard = false) { if (!this.isMyRenote) return; - this.$root.menu({ - items: [{ - text: this.$t('unrenote'), - icon: faTrashAlt, - action: () => { - this.$root.api('notes/delete', { - noteId: this.note.id - }); - this.isDeleted = true; - } - }], - source: this.$refs.renoteTime, + os.modalMenu([{ + text: this.$t('unrenote'), + icon: faTrashAlt, + action: () => { + os.api('notes/delete', { + noteId: this.note.id + }); + this.isDeleted = true; + } + }], this.$refs.renoteTime, { viaKeyboard: viaKeyboard }); }, @@ -715,31 +725,20 @@ export default Vue.extend({ copyContent() { copyToClipboard(this.appearNote.text); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); }, copyLink() { copyToClipboard(`${url}/notes/${this.appearNote.id}`); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); }, togglePin(pin: boolean) { - this.$root.api(pin ? 'i/pin' : 'i/unpin', { + os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { noteId: this.appearNote.id - }).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }).catch(e => { + }, undefined, null, e => { if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { - this.$root.dialog({ + os.dialog({ type: 'error', text: this.$t('pinLimitExceeded') }); @@ -748,26 +747,16 @@ export default Vue.extend({ }, async promote() { - const { canceled, result: days } = await this.$root.dialog({ + const { canceled, result: days } = await os.dialog({ title: this.$t('numberOfDays'), input: { type: 'number' } }); if (canceled) return; - this.$root.api('admin/promo/create', { + os.apiWithDialog('admin/promo/create', { noteId: this.appearNote.id, expiresAt: Date.now() + (86400000 * days) - }).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); }); }, @@ -785,7 +774,9 @@ export default Vue.extend({ focusAfter() { focusNext(this.$el); - } + }, + + userPage } }); </script> @@ -795,10 +786,28 @@ export default Vue.extend({ position: relative; transition: box-shadow 0.1s ease; overflow: hidden; + contain: content; &:focus { outline: none; - box-shadow: 0 0 0 3px var(--focus); + + &:after { + content: ""; + pointer-events: none; + display: block; + position: absolute; + z-index: 10; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: calc(100% - 8px); + height: calc(100% - 8px); + border: dashed 1px var(--focus); + border-radius: var(--radius); + box-sizing: border-box; + } } &:hover > .article > .main > .footer > .button { diff --git a/src/client/components/notes.vue b/src/client/components/notes.vue index 2ae8f696f6..f2ea7e929b 100644 --- a/src/client/components/notes.vue +++ b/src/client/components/notes.vue @@ -1,42 +1,41 @@ <template> -<div class="mk-notes"> +<div class="_list_"> <div class="_fullinfo" v-if="empty"> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> <div>{{ $t('noNotes') }}</div> </div> - <mk-error v-if="error" @retry="init()"/> + <MkError v-if="error" @retry="init()"/> <div v-show="more && reversed" style="margin-bottom: var(--margin);"> - <button class="_panel _button" ref="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <button class="_loadMore" v-appear="$store.state.device.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> <template v-if="!moreFetching">{{ $t('loadMore') }}</template> - <template v-if="moreFetching"><mk-loading inline/></template> + <template v-if="moreFetching"><MkLoading inline/></template> </button> </div> - <x-list ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed"> - <x-note :note="note" @updated="updated(note, $event)" :detail="detail" :key="note._featuredId_ || note._prId_ || note.id"/> - </x-list> + <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed"> + <XNote :note="note" @update:note="updated(note, $event)" :detail="detail" :key="note._featuredId_ || note._prId_ || note.id"/> + </XList> <div v-show="more && !reversed" style="margin-top: var(--margin);"> - <button class="_panel _button" ref="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <button class="_loadMore" v-appear="$store.state.device.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> <template v-if="!moreFetching">{{ $t('loadMore') }}</template> - <template v-if="moreFetching"><mk-loading inline/></template> + <template v-if="moreFetching"><MkLoading inline/></template> </button> </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; -import paging from '../scripts/paging'; +import { defineComponent } from 'vue'; +import paging from '@/scripts/paging'; import XNote from './note.vue'; import XList from './date-separated-list.vue'; -import MkButton from './ui/button.vue'; -export default Vue.extend({ +export default defineComponent({ components: { - XNote, XList, MkButton + XNote, XList, }, mixins: [ @@ -68,6 +67,8 @@ export default Vue.extend({ } }, + emits: ['before', 'after'], + computed: { notes(): any[] { return this.prop ? this.items.map(item => item[this.prop]) : this.items; @@ -82,9 +83,9 @@ export default Vue.extend({ updated(oldValue, newValue) { const i = this.notes.findIndex(n => n === oldValue); if (this.prop) { - Vue.set(this.items[i], this.prop, newValue); + this.items[i][this.prop] = newValue; } else { - Vue.set(this.items, i, newValue); + this.items[i] = newValue; } }, @@ -94,4 +95,3 @@ export default Vue.extend({ } }); </script> - diff --git a/src/client/components/notification-setting-window.vue b/src/client/components/notification-setting-window.vue index d63a3d48a5..e6d109e3a5 100644 --- a/src/client/components/notification-setting-window.vue +++ b/src/client/components/notification-setting-window.vue @@ -1,34 +1,40 @@ <template> -<x-window ref="window" :width="400" :height="450" :no-padding="true" @closed="() => { $emit('closed'); destroyDom(); }" :with-ok-button="true" :ok-button-disabled="false" @ok="ok()"> +<XModalWindow ref="dialog" + :width="400" + :height="450" + :with-ok-button="true" + :ok-button-disabled="false" + @ok="ok()" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> <template #header>{{ $t('notificationSetting') }}</template> - <div class="vv94n3oa"> - <div v-if="showGlobalToggle"> - <mk-switch v-model="useGlobalSetting"> - {{ $t('useGlobalSetting') }} - <template #desc>{{ $t('useGlobalSettingDesc') }}</template> - </mk-switch> - </div> - <div v-if="!useGlobalSetting"> - <mk-info>{{ $t('notificationSettingDesc') }}</mk-info> - <mk-button inline @click="disableAll">{{ $t('disableAll') }}</mk-button> - <mk-button inline @click="enableAll">{{ $t('enableAll') }}</mk-button> - <mk-switch v-for="type in notificationTypes" :key="type" v-model="typesMap[type]">{{ $t(`_notification._types.${type}`) }}</mk-switch> - </div> + <div v-if="showGlobalToggle" class="_section"> + <MkSwitch v-model:value="useGlobalSetting"> + {{ $t('useGlobalSetting') }} + <template #desc>{{ $t('useGlobalSettingDesc') }}</template> + </MkSwitch> </div> -</x-window> + <div v-if="!useGlobalSetting" class="_section"> + <MkInfo>{{ $t('notificationSettingDesc') }}</MkInfo> + <MkButton inline @click="disableAll">{{ $t('disableAll') }}</MkButton> + <MkButton inline @click="enableAll">{{ $t('enableAll') }}</MkButton> + <MkSwitch v-for="type in notificationTypes" :key="type" v-model:value="typesMap[type]">{{ $t(`_notification._types.${type}`) }}</MkSwitch> + </div> +</XModalWindow> </template> <script lang="ts"> -import Vue, { PropType } from 'vue'; -import XWindow from './window.vue'; +import { defineComponent, PropType } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; import MkSwitch from './ui/switch.vue'; import MkInfo from './ui/info.vue'; import MkButton from './ui/button.vue'; import { notificationTypes } from '../../types'; -export default Vue.extend({ +export default defineComponent({ components: { - XWindow, + XModalWindow, MkSwitch, MkInfo, MkButton @@ -48,6 +54,8 @@ export default Vue.extend({ } }, + emits: ['done', 'closed'], + data() { return { typesMap: {} as Record<typeof notificationTypes[number], boolean>, @@ -60,7 +68,7 @@ export default Vue.extend({ this.useGlobalSetting = this.includingTypes === null && this.showGlobalToggle; for (const type of this.notificationTypes) { - Vue.set(this.typesMap, type, this.includingTypes === null || this.includingTypes.includes(type)); + this.typesMap[type] = this.includingTypes === null || this.includingTypes.includes(type); } }, @@ -69,8 +77,8 @@ export default Vue.extend({ const includingTypes = this.useGlobalSetting ? null : (Object.keys(this.typesMap) as typeof notificationTypes[number][]) .filter(type => this.typesMap[type]); - this.$emit('ok', { includingTypes }); - this.$refs.window.close(); + this.$emit('done', { includingTypes }); + this.$refs.dialog.close(); }, disableAll() { @@ -87,12 +95,3 @@ export default Vue.extend({ } }); </script> - -<style lang="scss" scoped> -.vv94n3oa { - > div { - border-top: solid 1px var(--divider); - padding: 24px; - } -} -</style> diff --git a/src/client/components/notification.vue b/src/client/components/notification.vue index 71ac963a58..ab890bbf0f 100644 --- a/src/client/components/notification.vue +++ b/src/client/components/notification.vue @@ -1,71 +1,75 @@ <template> <div class="qglefbjs" :class="notification.type" v-size="{ max: [500, 600] }"> <div class="head"> - <mk-avatar v-if="notification.user" class="icon" :user="notification.user"/> - <img v-else class="icon" :src="notification.icon" alt=""/> + <MkAvatar v-if="notification.user" class="icon" :user="notification.user"/> + <img v-else-if="notification.icon" class="icon" :src="notification.icon" alt=""/> <div class="sub-icon" :class="notification.type"> - <fa :icon="faPlus" v-if="notification.type === 'follow'"/> - <fa :icon="faClock" v-else-if="notification.type === 'receiveFollowRequest'"/> - <fa :icon="faCheck" v-else-if="notification.type === 'followRequestAccepted'"/> - <fa :icon="faIdCardAlt" v-else-if="notification.type === 'groupInvited'"/> - <fa :icon="faRetweet" v-else-if="notification.type === 'renote'"/> - <fa :icon="faReply" v-else-if="notification.type === 'reply'"/> - <fa :icon="faAt" v-else-if="notification.type === 'mention'"/> - <fa :icon="faQuoteLeft" v-else-if="notification.type === 'quote'"/> - <fa :icon="faPollH" v-else-if="notification.type === 'pollVote'"/> - <x-reaction-icon v-else-if="notification.type === 'reaction'" :reaction="notification.reaction" :custom-emojis="notification.note.emojis" :no-style="true"/> + <Fa :icon="faPlus" v-if="notification.type === 'follow'"/> + <Fa :icon="faClock" v-else-if="notification.type === 'receiveFollowRequest'"/> + <Fa :icon="faCheck" v-else-if="notification.type === 'followRequestAccepted'"/> + <Fa :icon="faIdCardAlt" v-else-if="notification.type === 'groupInvited'"/> + <Fa :icon="faRetweet" v-else-if="notification.type === 'renote'"/> + <Fa :icon="faReply" v-else-if="notification.type === 'reply'"/> + <Fa :icon="faAt" v-else-if="notification.type === 'mention'"/> + <Fa :icon="faQuoteLeft" v-else-if="notification.type === 'quote'"/> + <Fa :icon="faPollH" v-else-if="notification.type === 'pollVote'"/> + <XReactionIcon v-else-if="notification.type === 'reaction'" :reaction="notification.reaction" :custom-emojis="notification.note.emojis" :no-style="true"/> </div> </div> <div class="tail"> <header> - <router-link v-if="notification.user" class="name" :to="notification.user | userPage" v-user-preview="notification.user.id"><mk-user-name :user="notification.user"/></router-link> + <router-link v-if="notification.user" class="name" :to="userPage(notification.user)" v-user-preview="notification.user.id"><MkUserName :user="notification.user"/></router-link> <span v-else>{{ notification.header }}</span> - <mk-time :time="notification.createdAt" v-if="withTime"/> + <MkTime :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="!full" :custom-emojis="notification.note.emojis"/> - <fa :icon="faQuoteRight"/> + <router-link v-if="notification.type === 'reaction'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <Fa :icon="faQuoteLeft"/> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :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="!full" :custom-emojis="notification.note.renote.emojis"/> - <fa :icon="faQuoteRight"/> + <router-link v-if="notification.type === 'renote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)"> + <Fa :icon="faQuoteLeft"/> + <Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="!full" :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="!full" :custom-emojis="notification.note.emojis"/> + <router-link v-if="notification.type === 'reply'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> </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="!full" :custom-emojis="notification.note.emojis"/> + <router-link v-if="notification.type === 'mention'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> </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="!full" :custom-emojis="notification.note.emojis"/> + <router-link v-if="notification.type === 'quote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> </router-link> - <router-link v-if="notification.type === 'pollVote'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> - <fa :icon="faQuoteLeft"/> - <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> - <fa :icon="faQuoteRight"/> + <router-link v-if="notification.type === 'pollVote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <Fa :icon="faQuoteLeft"/> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> + <Fa :icon="faQuoteRight"/> </router-link> - <span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ $t('youGotNewFollower') }}<div v-if="full"><mk-follow-button :user="notification.user" :full="true"/></div></span> + <span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ $t('youGotNewFollower') }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span> <span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ $t('followRequestAccepted') }}</span> <span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ $t('receiveFollowRequest') }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ $t('accept') }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ $t('reject') }}</button></div></span> <span v-if="notification.type === 'groupInvited'" class="text" style="opacity: 0.6;">{{ $t('groupInvited') }}: <b>{{ notification.invitation.group.name }}</b><div v-if="full && !groupInviteDone"><button class="_textButton" @click="acceptGroupInvitation()">{{ $t('accept') }}</button> | <button class="_textButton" @click="rejectGroupInvitation()">{{ $t('reject') }}</button></div></span> <span v-if="notification.type === 'app'" class="text"> - <mfm :text="notification.body" :nowrap="!full"/> + <Mfm :text="notification.body" :nowrap="!full"/> </span> </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faIdCardAlt, faPlus, faQuoteLeft, faQuoteRight, faRetweet, faReply, faAt, faCheck, faPollH } from '@fortawesome/free-solid-svg-icons'; import { faClock } from '@fortawesome/free-regular-svg-icons'; import noteSummary from '../../misc/get-note-summary'; import XReactionIcon from './reaction-icon.vue'; import MkFollowButton from './follow-button.vue'; +import notePage from '../filters/note'; +import { userPage } from '../filters/user'; +import { locale } from '../i18n'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XReactionIcon, MkFollowButton }, @@ -87,7 +91,7 @@ export default Vue.extend({ }, data() { return { - getNoteSummary: (text: string) => noteSummary(text, this.$root.i18n.messages[this.$root.i18n.locale]), + getNoteSummary: (text: string) => noteSummary(text, locale), followRequestDone: false, groupInviteDone: false, connection: null, @@ -100,7 +104,7 @@ export default Vue.extend({ if (!this.notification.isRead) { this.readObserver = new IntersectionObserver((entries, observer) => { if (!entries.some(entry => entry.isIntersecting)) return; - this.$root.stream.send('readNotification', { + os.stream.send('readNotification', { id: this.notification.id }); entries.map(({ target }) => observer.unobserve(target)); @@ -108,12 +112,12 @@ export default Vue.extend({ this.readObserver.observe(this.$el); - this.connection = this.$root.stream.useSharedConnection('main'); + this.connection = os.stream.useSharedConnection('main'); this.connection.on('readAllNotifications', () => this.readObserver.unobserve(this.$el)); } }, - beforeDestroy() { + beforeUnmount() { if (!this.notification.isRead) { this.readObserver.unobserve(this.$el); this.connection.dispose(); @@ -123,24 +127,22 @@ export default Vue.extend({ methods: { acceptFollowRequest() { this.followRequestDone = true; - this.$root.api('following/requests/accept', { userId: this.notification.user.id }); + os.api('following/requests/accept', { userId: this.notification.user.id }); }, rejectFollowRequest() { this.followRequestDone = true; - this.$root.api('following/requests/reject', { userId: this.notification.user.id }); + os.api('following/requests/reject', { userId: this.notification.user.id }); }, acceptGroupInvitation() { this.groupInviteDone = true; - this.$root.api('users/groups/invitations/accept', { invitationId: this.notification.invitation.id }); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.apiWithDialog('users/groups/invitations/accept', { invitationId: this.notification.invitation.id }); }, rejectGroupInvitation() { this.groupInviteDone = true; - this.$root.api('users/groups/invitations/reject', { invitationId: this.notification.invitation.id }); + os.api('users/groups/invitations/reject', { invitationId: this.notification.invitation.id }); }, + notePage, + userPage } }); </script> @@ -153,6 +155,7 @@ export default Vue.extend({ font-size: 0.9em; overflow-wrap: break-word; display: flex; + contain: content; &.max-width_600px { padding: 16px; diff --git a/src/client/components/notifications.vue b/src/client/components/notifications.vue index 0e512e1967..3eedf86558 100644 --- a/src/client/components/notifications.vue +++ b/src/client/components/notifications.vue @@ -1,30 +1,31 @@ <template> <div class="mfcuwfyp"> - <x-list class="notifications" :items="items" v-slot="{ item: notification }"> - <x-note v-if="['reply', 'quote', 'mention'].includes(notification.type)" :note="notification.note" @updated="noteUpdated(notification.note, $event)" :key="notification.id"/> - <x-notification v-else :notification="notification" :with-time="true" :full="true" class="_panel notification" :key="notification.id"/> - </x-list> + <XList class="notifications" :items="items" v-slot="{ item: notification }"> + <XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :note="notification.note" @update:note="noteUpdated(notification.note, $event)" :key="notification.id"/> + <XNotification v-else :notification="notification" :with-time="true" :full="true" class="_panel notification" :key="notification.id"/> + </XList> - <button class="_panel _button" ref="loadMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <button class="_loadMore" v-appear="$store.state.device.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> <template v-if="!moreFetching">{{ $t('loadMore') }}</template> - <template v-if="moreFetching"><mk-loading inline/></template> + <template v-if="moreFetching"><MkLoading inline/></template> </button> <p class="empty" v-if="empty">{{ $t('noNotifications') }}</p> - <mk-error v-if="error" @retry="init()"/> + <MkError v-if="error" @retry="init()"/> </div> </template> <script lang="ts"> -import Vue, { PropType } from 'vue'; -import paging from '../scripts/paging'; +import { defineComponent, PropType } from 'vue'; +import paging from '@/scripts/paging'; import XNotification from './notification.vue'; import XList from './date-separated-list.vue'; import XNote from './note.vue'; import { notificationTypes } from '../../types'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XNotification, XList, @@ -63,22 +64,30 @@ export default Vue.extend({ }, watch: { - includeTypes() { - this.reload(); - }, - '$store.state.i.mutingNotificationTypes'() { - if (this.includeTypes === null) { + includeTypes: { + handler() { this.reload(); - } + }, + deep: true + }, + // TODO: vue/vuexのバグか仕様かは不明なものの、プロフィール更新するなどして $store.state.i が更新されると、 + // mutingNotificationTypes に変化が無くてもこのハンドラーが呼び出され無駄なリロードが発生するのを直す + '$store.state.i.mutingNotificationTypes': { + handler() { + if (this.includeTypes === null) { + this.reload(); + } + }, + deep: true } }, mounted() { - this.connection = this.$root.stream.useSharedConnection('main'); + this.connection = os.stream.useSharedConnection('main'); this.connection.on('notification', this.onNotification); }, - beforeDestroy() { + beforeUnmount() { this.connection.dispose(); }, @@ -86,7 +95,7 @@ export default Vue.extend({ onNotification(notification) { const isMuted = !this.allIncludeTypes.includes(notification.type); if (isMuted || document.visibilityState === 'visible') { - this.$root.stream.send('readNotification', { + os.stream.send('readNotification', { id: notification.id }); } @@ -101,10 +110,10 @@ export default Vue.extend({ noteUpdated(oldValue, newValue) { const i = this.items.findIndex(n => n.note === oldValue); - Vue.set(this.items, i, { + this.items[i] = { ...this.items[i], note: newValue - }); + }; }, } }); diff --git a/src/client/components/page-preview.vue b/src/client/components/page-preview.vue index 8c41a070bd..ad1069f53f 100644 --- a/src/client/components/page-preview.vue +++ b/src/client/components/page-preview.vue @@ -8,22 +8,27 @@ <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> + <p>{{ userName(page.user) }}</p> </footer> </article> </router-link> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; +import { userName } from '../filters/user'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { page: { type: Object, required: true }, }, + methods: { + userName + } }); </script> diff --git a/src/client/components/page-window.vue b/src/client/components/page-window.vue new file mode 100644 index 0000000000..77312fec7f --- /dev/null +++ b/src/client/components/page-window.vue @@ -0,0 +1,86 @@ +<template> +<XWindow ref="window" :initial-width="400" :initial-height="450" :can-resize="true" @closed="$emit('closed')"> + <template #header> + <XHeader :info="pageInfo" :with-back="false"/> + </template> + <template #buttons> + <button class="_button" @click="expand" v-tooltip="$t('showInPage')"><Fa :icon="faExpandAlt"/></button> + <button class="_button" @click="popout" v-tooltip="$t('popout')"><Fa :icon="faExternalLinkAlt"/></button> + </template> + <div style="min-height: 100%; background: var(--bg);"> + <component :is="component" v-bind="props" :ref="changePage"/> + </div> +</XWindow> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import { faExternalLinkAlt, faExpandAlt } from '@fortawesome/free-solid-svg-icons'; +import XWindow from '@/components/ui/window.vue'; +import XHeader from '@/ui/_common_/header.vue'; +import { popout } from '@/scripts/popout'; + +export default defineComponent({ + components: { + XWindow, + XHeader, + }, + + props: { + initialUrl: { + type: String, + required: true, + }, + initialComponent: { + type: Object, + required: true, + }, + initialProps: { + type: Object, + required: false, + default: {}, + }, + }, + + emits: ['closed'], + + data() { + return { + pageInfo: null, + url: this.initialUrl, + component: this.initialComponent, + props: this.initialProps, + faExternalLinkAlt, faExpandAlt, + }; + }, + + provide() { + return { + navHook: (url, component, props) => { + this.url = url; + this.component = markRaw(component); + this.props = props; + } + }; + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page.INFO) { + this.pageInfo = page.INFO; + } + }, + + expand() { + this.$router.push(this.url); + this.$refs.window.close(); + }, + + popout() { + popout(this.url, this.$el); + this.$refs.window.close(); + }, + }, +}); +</script> diff --git a/src/client/components/page/page.block.vue b/src/client/components/page/page.block.vue index 0a4b068b63..412c91ee0d 100644 --- a/src/client/components/page/page.block.vue +++ b/src/client/components/page/page.block.vue @@ -3,7 +3,7 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import XText from './page.text.vue'; import XSection from './page.section.vue'; import XImage from './page.image.vue'; @@ -19,7 +19,7 @@ import XCounter from './page.counter.vue'; import XRadioButton from './page.radio-button.vue'; import XCanvas from './page.canvas.vue'; -export default Vue.extend({ +export default defineComponent({ components: { XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton, XCanvas }, diff --git a/src/client/components/page/page.button.vue b/src/client/components/page/page.button.vue index 83753b4e80..ff4c88554b 100644 --- a/src/client/components/page/page.button.vue +++ b/src/client/components/page/page.button.vue @@ -1,14 +1,15 @@ <template> <div> - <mk-button class="kudkigyw" @click="click()" :primary="value.primary">{{ hpml.interpolate(value.text) }}</mk-button> + <MkButton class="kudkigyw" @click="click()" :primary="value.primary">{{ hpml.interpolate(value.text) }}</MkButton> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import MkButton from '../ui/button.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkButton }, @@ -24,14 +25,14 @@ export default Vue.extend({ click() { if (this.value.action === 'dialog') { this.hpml.eval(); - this.$root.dialog({ + os.dialog({ text: this.hpml.interpolate(this.value.content) }); } else if (this.value.action === 'resetRandom') { this.hpml.updateRandomSeed(Math.random()); this.hpml.eval(); } else if (this.value.action === 'pushEvent') { - this.$root.api('page-push', { + os.api('page-push', { pageId: this.hpml.page.id, event: this.value.event, ...(this.value.var ? { @@ -39,7 +40,7 @@ export default Vue.extend({ } : {}) }); - this.$root.dialog({ + os.dialog({ type: 'success', text: this.hpml.interpolate(this.value.message) }); diff --git a/src/client/components/page/page.canvas.vue b/src/client/components/page/page.canvas.vue index 2d7cc1f8f9..d3bf5c1de6 100644 --- a/src/client/components/page/page.canvas.vue +++ b/src/client/components/page/page.canvas.vue @@ -5,9 +5,10 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { value: { required: true diff --git a/src/client/components/page/page.counter.vue b/src/client/components/page/page.counter.vue index a3674b87a2..9eee47c4ec 100644 --- a/src/client/components/page/page.counter.vue +++ b/src/client/components/page/page.counter.vue @@ -1,14 +1,15 @@ <template> <div> - <mk-button class="llumlmnx" @click="click()">{{ hpml.interpolate(value.text) }}</mk-button> + <MkButton class="llumlmnx" @click="click()">{{ hpml.interpolate(value.text) }}</MkButton> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import MkButton from '../ui/button.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkButton }, diff --git a/src/client/components/page/page.if.vue b/src/client/components/page/page.if.vue index 6bdf9cd97d..1e0c841541 100644 --- a/src/client/components/page/page.if.vue +++ b/src/client/components/page/page.if.vue @@ -1,13 +1,14 @@ <template> <div v-show="hpml.vars[value.var]"> - <x-block v-for="child in value.children" :value="child" :page="page" :hpml="hpml" :key="child.id" :h="h"/> + <XBlock v-for="child in value.children" :value="child" :page="page" :hpml="hpml" :key="child.id" :h="h"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { value: { required: true diff --git a/src/client/components/page/page.image.vue b/src/client/components/page/page.image.vue index f515958515..ddcce5c37b 100644 --- a/src/client/components/page/page.image.vue +++ b/src/client/components/page/page.image.vue @@ -5,9 +5,10 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { value: { required: true diff --git a/src/client/components/page/page.number-input.vue b/src/client/components/page/page.number-input.vue index 56899b1b20..cf4000010f 100644 --- a/src/client/components/page/page.number-input.vue +++ b/src/client/components/page/page.number-input.vue @@ -1,14 +1,15 @@ <template> <div> - <mk-input class="kudkigyw" v-model="v" type="number">{{ hpml.interpolate(value.text) }}</mk-input> + <MkInput class="kudkigyw" v-model:value="v" type="number">{{ hpml.interpolate(value.text) }}</MkInput> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import MkInput from '../ui/input.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkInput }, diff --git a/src/client/components/page/page.post.vue b/src/client/components/page/page.post.vue index da5bc8bfab..e2b712667a 100644 --- a/src/client/components/page/page.post.vue +++ b/src/client/components/page/page.post.vue @@ -1,18 +1,19 @@ <template> <div class="ngbfujlo"> - <mk-textarea :value="text" readonly style="margin: 0;"></mk-textarea> - <mk-button class="button" primary @click="post()" :disabled="posting || posted"><fa v-if="posted" :icon="faCheck"/><fa v-else :icon="faPaperPlane"/></mk-button> + <MkTextarea :value="text" readonly style="margin: 0;"></MkTextarea> + <MkButton class="button" primary @click="post()" :disabled="posting || posted"><Fa v-if="posted" :icon="faCheck"/><Fa v-else :icon="faPaperPlane"/></MkButton> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faCheck, faPaperPlane } from '@fortawesome/free-solid-svg-icons'; import MkTextarea from '../ui/textarea.vue'; import MkButton from '../ui/button.vue'; -import { apiUrl } from '../../config'; +import { apiUrl } from '@/config'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkTextarea, MkButton, @@ -44,7 +45,7 @@ export default Vue.extend({ methods: { upload() { return new Promise((ok) => { - const dialog = this.$root.dialog({ + const dialog = os.dialog({ type: 'waiting', text: this.$t('uploading') + '...', showOkButton: false, @@ -75,15 +76,11 @@ export default Vue.extend({ async post() { this.posting = true; const file = this.value.attachCanvasImage ? await this.upload() : null; - this.$root.api('notes/create', { + os.apiWithDialog('notes/create', { text: this.text === '' ? null : this.text, fileIds: file ? [file.id] : undefined, }).then(() => { this.posted = true; - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); }); } } diff --git a/src/client/components/page/page.radio-button.vue b/src/client/components/page/page.radio-button.vue index 99d9ead385..9341c2421e 100644 --- a/src/client/components/page/page.radio-button.vue +++ b/src/client/components/page/page.radio-button.vue @@ -1,15 +1,16 @@ <template> <div> <div>{{ hpml.interpolate(value.title) }}</div> - <mk-radio v-for="x in value.values" v-model="v" :value="x" :key="x">{{ x }}</mk-radio> + <MkRadio v-for="x in value.values" v-model:value="v" :value="x" :key="x">{{ x }}</MkRadio> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import MkRadio from '../ui/radio.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkRadio }, diff --git a/src/client/components/page/page.section.vue b/src/client/components/page/page.section.vue index c9758a0dbe..d31610bc87 100644 --- a/src/client/components/page/page.section.vue +++ b/src/client/components/page/page.section.vue @@ -3,15 +3,16 @@ <component :is="'h' + h">{{ value.title }}</component> <div class="children"> - <x-block v-for="child in value.children" :value="child" :page="page" :hpml="hpml" :key="child.id" :h="h + 1"/> + <XBlock v-for="child in value.children" :value="child" :page="page" :hpml="hpml" :key="child.id" :h="h + 1"/> </div> </section> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { value: { required: true diff --git a/src/client/components/page/page.switch.vue b/src/client/components/page/page.switch.vue index 9f04ad19c4..33e4371695 100644 --- a/src/client/components/page/page.switch.vue +++ b/src/client/components/page/page.switch.vue @@ -1,14 +1,15 @@ <template> <div class="hkcxmtwj"> - <mk-switch v-model="v">{{ hpml.interpolate(value.text) }}</mk-switch> + <MkSwitch v-model:value="v">{{ hpml.interpolate(value.text) }}</MkSwitch> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import MkSwitch from '../ui/switch.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkSwitch }, diff --git a/src/client/components/page/page.text-input.vue b/src/client/components/page/page.text-input.vue index 0d09f9fb5e..f0fe70e335 100644 --- a/src/client/components/page/page.text-input.vue +++ b/src/client/components/page/page.text-input.vue @@ -1,14 +1,15 @@ <template> <div> - <mk-input class="kudkigyw" v-model="v" type="text">{{ hpml.interpolate(value.text) }}</mk-input> + <MkInput class="kudkigyw" v-model:value="v" type="text">{{ hpml.interpolate(value.text) }}</MkInput> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import MkInput from '../ui/input.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkInput }, diff --git a/src/client/components/page/page.text.vue b/src/client/components/page/page.text.vue index 66e2acb90a..fff840f743 100644 --- a/src/client/components/page/page.text.vue +++ b/src/client/components/page/page.text.vue @@ -1,16 +1,19 @@ <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"/> + <Mfm :text="text" :is-note="false" :i="$store.state.i" :key="text"/> + <MkUrlPreview v-for="url in urls" :url="url" :key="url" class="url"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineAsyncComponent, defineComponent } from 'vue'; import { parse } from '../../../mfm/parse'; import { unique } from '../../../prelude/array'; -export default Vue.extend({ +export default defineComponent({ + components: { + MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), + }, props: { value: { required: true diff --git a/src/client/components/page/page.textarea-input.vue b/src/client/components/page/page.textarea-input.vue index 5e0cc43779..9e3283aa08 100644 --- a/src/client/components/page/page.textarea-input.vue +++ b/src/client/components/page/page.textarea-input.vue @@ -1,14 +1,15 @@ <template> <div> - <mk-textarea v-model="v">{{ hpml.interpolate(value.text) }}</mk-textarea> + <MkTextarea v-model:value="v">{{ hpml.interpolate(value.text) }}</MkTextarea> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import MkTextarea from '../ui/textarea.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkTextarea }, diff --git a/src/client/components/page/page.textarea.vue b/src/client/components/page/page.textarea.vue index abb30d78ee..e2929de5f0 100644 --- a/src/client/components/page/page.textarea.vue +++ b/src/client/components/page/page.textarea.vue @@ -1,12 +1,13 @@ <template> -<mk-textarea :value="text" readonly></mk-textarea> +<MkTextarea :value="text" readonly></MkTextarea> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import MkTextarea from '../ui/textarea.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkTextarea }, diff --git a/src/client/components/page/page.vue b/src/client/components/page/page.vue index b3cc01ec22..f7f565a8d1 100644 --- a/src/client/components/page/page.vue +++ b/src/client/components/page/page.vue @@ -1,19 +1,20 @@ <template> <div class="iroscrza" :class="{ center: page.alignCenter, serif: page.font === 'serif' }" v-if="hpml"> - <x-block v-for="child in page.content" :value="child" @input="v => updateBlock(v)" :page="page" :hpml="hpml" :key="child.id" :h="2"/> + <XBlock v-for="child in page.content" :value="child" @update:value="v => updateBlock(v)" :page="page" :hpml="hpml" :key="child.id" :h="2"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { parse } from '@syuilo/aiscript'; 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 { Hpml } from '../../scripts/hpml/evaluator'; -import { url } from '../../config'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { url } from '@/config'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XBlock }, @@ -33,7 +34,7 @@ export default Vue.extend({ }, created() { - this.hpml = new Hpml(this, this.page, { + this.hpml = new Hpml(this.page, { randomSeed: Math.random(), visitor: this.$store.state.i, url: url, @@ -49,7 +50,7 @@ export default Vue.extend({ ast = parse(this.page.script); } catch (e) { console.error(e); - /*this.$root.dialog({ + /*os.dialog({ type: 'error', text: 'Syntax error :(' });*/ @@ -59,7 +60,7 @@ export default Vue.extend({ this.hpml.eval(); }).catch(e => { console.error(e); - /*this.$root.dialog({ + /*os.dialog({ type: 'error', text: e });*/ @@ -70,7 +71,7 @@ export default Vue.extend({ }); }, - beforeDestroy() { + beforeUnmount() { if (this.hpml.aiscript) this.hpml.aiscript.abort(); }, }); diff --git a/src/client/components/particle.vue b/src/client/components/particle.vue index bcdc56be63..d82705c1e8 100644 --- a/src/client/components/particle.vue +++ b/src/client/components/particle.vue @@ -3,43 +3,48 @@ <svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"> <circle fill="none" cx="64" cy="64"> <animate attributeName="r" - begin="0s" dur="0.5s" - values="4; 32" - calcMode="spline" - keyTimes="0; 1" - keySplines="0.165, 0.84, 0.44, 1" - repeatCount="1" /> + begin="0s" dur="0.5s" + values="4; 32" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.165, 0.84, 0.44, 1" + repeatCount="1" + /> <animate attributeName="stroke-width" - begin="0s" dur="0.5s" - values="16; 0" - calcMode="spline" - keyTimes="0; 1" - keySplines="0.3, 0.61, 0.355, 1" - repeatCount="1" /> + begin="0s" dur="0.5s" + values="16; 0" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.3, 0.61, 0.355, 1" + repeatCount="1" + /> </circle> <g fill="none" fill-rule="evenodd"> <circle v-for="(particle, i) in particles" :key="i" :fill="particle.color"> <animate attributeName="r" - begin="0s" dur="0.8s" - :values="`${particle.size}; 0`" - calcMode="spline" - keyTimes="0; 1" - keySplines="0.165, 0.84, 0.44, 1" - repeatCount="1" /> + begin="0s" dur="0.8s" + :values="`${particle.size}; 0`" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.165, 0.84, 0.44, 1" + repeatCount="1" + /> <animate attributeName="cx" - begin="0s" dur="0.8s" - :values="`${particle.xA}; ${particle.xB}`" - calcMode="spline" - keyTimes="0; 1" - keySplines="0.3, 0.61, 0.355, 1" - repeatCount="1" /> + begin="0s" dur="0.8s" + :values="`${particle.xA}; ${particle.xB}`" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.3, 0.61, 0.355, 1" + repeatCount="1" + /> <animate attributeName="cy" - begin="0s" dur="0.8s" - :values="`${particle.yA}; ${particle.yB}`" - calcMode="spline" - keyTimes="0; 1" - keySplines="0.3, 0.61, 0.355, 1" - repeatCount="1" /> + begin="0s" dur="0.8s" + :values="`${particle.yA}; ${particle.yB}`" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.3, 0.61, 0.355, 1" + repeatCount="1" + /> </circle> </g> </svg> @@ -47,9 +52,9 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; -export default Vue.extend({ +export default defineComponent({ props: { x: { type: Number, @@ -60,6 +65,7 @@ export default Vue.extend({ required: true } }, + emits: ['end'], data() { const particles = []; const origin = 64; @@ -85,7 +91,7 @@ export default Vue.extend({ }, mounted() { setTimeout(() => { - this.destroyDom(); + this.$emit('end'); }, 1100); } }); diff --git a/src/client/components/poll-editor.vue b/src/client/components/poll-editor.vue index 0687e999b5..5b615677dc 100644 --- a/src/client/components/poll-editor.vue +++ b/src/client/components/poll-editor.vue @@ -1,47 +1,47 @@ <template> <div class="zmdxowus"> <p class="caution" v-if="choices.length < 2"> - <fa :icon="faExclamationTriangle"/>{{ $t('_poll.noOnlyOneChoice') }} + <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)"> + <MkInput class="input" :value="choice" @update:value="onInput(i, $event)"> <span>{{ $t('_poll.choiceN', { n: i + 1 }) }}</span> - </mk-input> + </MkInput> <button @click="remove(i)" class="_button"> - <fa :icon="faTimes"/> + <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> + <MkButton class="add" v-if="choices.length < 10" @click="add">{{ $t('add') }}</MkButton> + <MkButton class="add" v-else disabled>{{ $t('_poll.noMore') }}</MkButton> <section> - <mk-switch v-model="multiple">{{ $t('_poll.canMultipleVote') }}</mk-switch> + <MkSwitch v-model:value="multiple">{{ $t('_poll.canMultipleVote') }}</MkSwitch> <div> - <mk-select v-model="expiration"> + <MkSelect v-model:value="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> + </MkSelect> <section v-if="expiration === 'at'"> - <mk-input v-model="atDate" type="date" class="input"> + <MkInput v-model:value="atDate" type="date" class="input"> <span>{{ $t('_poll.deadlineDate') }}</span> - </mk-input> - <mk-input v-model="atTime" type="time" class="input"> + </MkInput> + <MkInput v-model:value="atTime" type="time" class="input"> <span>{{ $t('_poll.deadlineTime') }}</span> - </mk-input> + </MkInput> </section> <section v-if="expiration === 'after'"> - <mk-input v-model="after" type="number" class="input"> + <MkInput v-model:value="after" type="number" class="input"> <span>{{ $t('_poll.duration') }}</span> - </mk-input> - <mk-select v-model="unit"> + </MkInput> + <MkSelect v-model:value="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> + </MkSelect> </section> </div> </section> @@ -49,23 +49,33 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faExclamationTriangle, faTimes } from '@fortawesome/free-solid-svg-icons'; -import { erase } from '../../prelude/array'; import { addTime } 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'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkInput, MkSelect, MkSwitch, MkButton, }, + + props: { + poll: { + type: Object, + required: true + } + }, + + emits: ['updated'], + data() { return { choices: ['', ''], @@ -78,20 +88,66 @@ export default Vue.extend({ faExclamationTriangle, faTimes }; }, + watch: { - choices() { - this.$emit('updated'); - } + poll: { + handler(poll) { + if (poll == null) return; + if (poll.choices.length == 0) return; + this.choices = poll.choices; + if (poll.choices.length == 1) this.choices = this.choices.concat(''); + this.multiple = poll.multiple; + if (poll.expiresAt) { + this.expiration = 'at'; + this.atDate = this.atTime = poll.expiresAt; + } else if (typeof poll.expiredAfter === 'number') { + this.expiration = 'after'; + this.after = poll.expiredAfter; + } else { + this.expiration = 'infinite'; + } + }, + deep: true, + immediate: true + }, + choices: { + handler() { + this.$emit('updated', this.get()); + }, + deep: true + }, + multiple: { + handler() { + this.$emit('updated', this.get()); + }, + }, + expiration: { + handler() { + this.$emit('updated', this.get()); + }, + }, + atDate: { + handler() { + this.$emit('updated', this.get()); + }, + }, + after: { + handler() { + this.$emit('updated', this.get()); + }, + }, }, + methods: { onInput(i, e) { - Vue.set(this.choices, i, e); + this.choices[i] = e; }, add() { this.choices.push(''); this.$nextTick(() => { - (this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus(); + // TODO + //(this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus(); }); }, @@ -116,29 +172,14 @@ export default Vue.extend({ }; return { - choices: erase('', this.choices), + choices: this.choices, multiple: this.multiple, ...( this.expiration === 'at' ? { expiresAt: at() } : - this.expiration === 'after' ? { expiredAfter: after() } : {}) + 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> diff --git a/src/client/components/poll.vue b/src/client/components/poll.vue index f67abf1543..071e3d539e 100644 --- a/src/client/components/poll.vue +++ b/src/client/components/poll.vue @@ -1,11 +1,11 @@ <template> -<div class="tivcixzd" :data-done="closed || isVoted"> +<div class="tivcixzd" :class="{ done: closed || isVoted }"> <ul> <li v-for="(choice, i) in poll.choices" :key="i" @click="vote(i)" :class="{ voted: choice.voted }"> <div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> <span> - <template v-if="choice.isVoted"><fa :icon="faCheck"/></template> - <mfm :text="choice.text" :plain="true" :custom-emojis="note.emojis"/> + <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> @@ -22,11 +22,12 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faCheck } from '@fortawesome/free-solid-svg-icons'; import { sum } from '../../prelude/array'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { note: { type: Object, @@ -85,7 +86,7 @@ export default Vue.extend({ }, vote(id) { if (this.closed || !this.poll.multiple && this.poll.choices.some(c => c.isVoted)) return; - this.$root.api('notes/polls/vote', { + os.api('notes/polls/vote', { noteId: this.note.id, choice: id }).then(() => { @@ -153,7 +154,7 @@ export default Vue.extend({ } } - &[data-done] { + &.done { > ul > li { cursor: default; diff --git a/src/client/components/popup.vue b/src/client/components/popup.vue deleted file mode 100644 index 6d27ff7ae0..0000000000 --- a/src/client/components/popup.vue +++ /dev/null @@ -1,148 +0,0 @@ -<template> -<div class="mk-popup" v-hotkey.global="keymap"> - <transition :name="$store.state.device.animation ? 'bg-fade' : ''" appear> - <div class="bg _modalBg" ref="bg" @click="close()" v-if="show"></div> - </transition> - <transition :name="$store.state.device.animation ? '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, - }; - }, - computed: { - keymap(): any { - return { - 'esc': this.close, - }; - }, - }, - 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; - if (this.$refs.bg) (this.$refs.bg as any).style.pointerEvents = 'none'; - if (this.$refs.content) (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 { - z-index: 10000; - } - - > .content { - position: absolute; - z-index: 10001; - background: var(--panel); - border-radius: 8px; - 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 index 2415bf28ec..6f3d1bca66 100644 --- a/src/client/components/post-form-attaches.vue +++ b/src/client/components/post-form-attaches.vue @@ -1,28 +1,28 @@ <template> <div class="skeikyzd" v-show="files.length != 0"> - <x-draggable class="files" :list="files" animation="150" delay="100" delayOnTouchOnly="true"> + <XDraggable class="files" :list="files" animation="150" delay="100" delay-on-touch-only="true"> <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"/> + <MkDriveFileThumbnail :data-id="file.id" class="thumbnail" :file="file" fit="cover"/> <div class="sensitive" v-if="file.isSensitive"> - <fa class="icon" :icon="faExclamationTriangle"/> + <Fa class="icon" :icon="faExclamationTriangle"/> </div> </div> - </x-draggable> + </XDraggable> <p class="remain">{{ 4 - files.length }}/4</p> </div> </template> <script lang="ts"> -import Vue from 'vue'; -import * as XDraggable from 'vuedraggable'; +import { defineComponent, defineAsyncComponent } from 'vue'; 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' +import MkDriveFileThumbnail from './drive-file-thumbnail.vue' +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { - XDraggable, - XFileThumbnail + XDraggable: defineAsyncComponent(() => import('vue-draggable-next').then(x => x.VueDraggableNext)), + MkDriveFileThumbnail }, props: { @@ -36,6 +36,8 @@ export default Vue.extend({ } }, + emits: ['updated', 'detach'], + data() { return { menu: null as Promise<null> | null, @@ -48,21 +50,21 @@ export default Vue.extend({ detachMedia(id) { if (this.detachMediaFn) { this.detachMediaFn(id); - } else if (this.$parent.detachMedia) { - this.$parent.detachMedia(id); + } else { + this.$emit('detach', id); } }, toggleSensitive(file) { - this.$root.api('drive/files/update', { + os.api('drive/files/update', { fileId: file.id, isSensitive: !file.isSensitive }).then(() => { file.isSensitive = !file.isSensitive; - this.$parent.updateMedia(file); + this.$emit('updated', file); }); }, async rename(file) { - const { canceled, result } = await this.$root.dialog({ + const { canceled, result } = await os.dialog({ title: this.$t('enterFileName'), input: { default: file.name @@ -70,32 +72,29 @@ export default Vue.extend({ allowEmpty: false }); if (canceled) return; - this.$root.api('drive/files/update', { + os.api('drive/files/update', { fileId: file.id, name: result }).then(() => { file.name = result; - this.$parent.updateMedia(file); + this.$emit('updated', file); }); }, showFileMenu(file, ev: MouseEvent) { if (this.menu) return; - this.menu = 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 - }).then(() => this.menu = null); + this.menu = os.modalMenu([{ + 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) } + }], ev.currentTarget || ev.target).then(() => this.menu = null); } } }); @@ -103,7 +102,7 @@ export default Vue.extend({ <style lang="scss" scoped> .skeikyzd { - padding: 4px; + padding: 8px 16px; position: relative; > .files { @@ -114,7 +113,9 @@ export default Vue.extend({ position: relative; width: 64px; height: 64px; - margin: 4px; + margin-right: 4px; + border-radius: 4px; + overflow: hidden; cursor: move; &:hover > .remove { diff --git a/src/client/components/post-form-dialog.vue b/src/client/components/post-form-dialog.vue index 8a56dd24e2..f2563a9bec 100644 --- a/src/client/components/post-form-dialog.vue +++ b/src/client/components/post-form-dialog.vue @@ -1,156 +1,19 @@ <template> -<div class="ulveipgl"> - <transition :name="$store.state.device.animation ? 'form-fade' : ''" appear @after-leave="$emit('closed');"> - <div class="bg _modalBg" ref="bg" v-if="show" @click="close()"></div> - </transition> - <div class="main" ref="main" @click.self="close()" @keydown="onKeydown"> - <transition :name="$store.state.device.animation ? '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" - :channel="channel" - @posted="onPosted" - @cancel="onCanceled" - style="border-radius: var(--radius);"/> - </transition> - </div> -</div> +<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')" :position="'top'"> + <MkPostForm @done="$refs.modal.close()" @esc="$refs.modal.close()" v-bind="$attrs"/> +</MkModal> </template> <script lang="ts"> -import Vue from 'vue'; -import XPostForm from './post-form.vue'; +import { defineComponent } from 'vue'; +import MkModal from '@/components/ui/modal.vue'; +import MkPostForm from '@/components/post-form.vue'; -export default Vue.extend({ +export default defineComponent({ components: { - XPostForm + MkModal, + MkPostForm, }, - - 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 - }, - channel: { - type: Object, - required: 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(); - } - }, - } + emits: ['closed'], }); </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; -} - -.ulveipgl { - > .bg { - z-index: 10000; - } - - > .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/components/post-form.vue b/src/client/components/post-form.vue index a0d2cd153c..ba7770345a 100644 --- a/src/client/components/post-form.vue +++ b/src/client/components/post-form.vue @@ -1,84 +1,84 @@ <template> -<div class="gafaadew" +<div class="gafaadew" :class="{ modal, _popup: modal }" + v-size="{ max: [500] }" @dragover.stop="onDragover" @dragenter="onDragenter" @dragleave="onDragleave" @drop.stop="onDrop" > <header> - <button v-if="!fixed" class="cancel _button" @click="cancel"><fa :icon="faTimes"/></button> + <button v-if="!fixed" class="cancel _button" @click="cancel"><Fa :icon="faTimes"/></button> <div> - <span class="local-only" v-if="localOnly" v-text="$t('_visibility.localOnly')" /> <span class="text-count" :class="{ over: trimmedLength(text) > max }">{{ max - trimmedLength(text) }}</span> + <span class="local-only" v-if="localOnly"><Fa :icon="faBiohazard"/></span> <button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$t('visibility')" :disabled="channel != null"> - <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> + <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> - <button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}<fa :icon="reply ? faReply : renote ? faQuoteRight : faPaperPlane"/></button> + <button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}<Fa :icon="reply ? faReply : renote ? faQuoteRight : faPaperPlane"/></button> </div> </header> <div class="form" :class="{ fixed }"> - <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('quoteAttached') }}<button @click="quoteId = null"><fa icon="times"/></button></div> + <XNotePreview class="preview" v-if="reply" :note="reply"/> + <XNotePreview class="preview" v-if="renote" :note="renote"/> + <div class="with-quote" v-if="quoteId"><Fa icon="quote-left"/> {{ $t('quoteAttached') }}<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" :key="u.id"> - <mk-acct :user="u"/> - <button class="_button" @click="removeVisibleUser(u)"><fa :icon="faTimes"/></button> + <MkAcct :user="u"/> + <button class="_button" @click="removeVisibleUser(u)"><Fa :icon="faTimes"/></button> </span> - <button @click="addVisibleUser" class="_buttonPrimary"><fa :icon="faPlus" fixed-width/></button> + <button @click="addVisibleUser" class="_buttonPrimary"><Fa :icon="faPlus" fixed-width/></button> </div> </div> - <input v-show="useCw" ref="cw" class="cw" v-model="cw" :placeholder="$t('annotation')" v-autocomplete="{ model: 'cw' }" @keydown="onKeydown"> - <textarea v-model="text" class="text" :class="{ withCw: useCw }" 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"/> + <input v-show="useCw" ref="cw" class="cw" v-model="cw" :placeholder="$t('annotation')" @keydown="onKeydown"> + <textarea v-model="text" class="text" :class="{ withCw: useCw }" ref="text" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste"></textarea> + <XPostFormAttaches class="attaches" :files="files" @updated="updateMedia" @detach="detachMedia"/> + <XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/> <footer> - <button class="_button" @click="chooseFileFrom" v-tooltip="$t('attachFile')"><fa :icon="faPhotoVideo"/></button> - <button class="_button" @click="poll = !poll" :class="{ active: poll }" v-tooltip="$t('poll')"><fa :icon="faPollH"/></button> - <button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$t('useCw')"><fa :icon="faEyeSlash"/></button> - <button class="_button" @click="insertMention" v-tooltip="$t('mention')"><fa :icon="faAt"/></button> - <button class="_button" @click="insertEmoji" v-tooltip="$t('emoji')"><fa :icon="faLaughSquint"/></button> - <button class="_button" @click="showActions" v-tooltip="$t('plugin')" v-if="$store.state.postFormActions.length > 0"><fa :icon="faPlug"/></button> + <button class="_button" @click="chooseFileFrom" v-tooltip="$t('attachFile')"><Fa :icon="faPhotoVideo"/></button> + <button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$t('poll')"><Fa :icon="faPollH"/></button> + <button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$t('useCw')"><Fa :icon="faEyeSlash"/></button> + <button class="_button" @click="insertMention" v-tooltip="$t('mention')"><Fa :icon="faAt"/></button> + <button class="_button" @click="insertEmoji" v-tooltip="$t('emoji')"><Fa :icon="faLaughSquint"/></button> + <button class="_button" @click="showActions" v-tooltip="$t('plugin')" v-if="postFormActions.length > 0"><Fa :icon="faPlug"/></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 { faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faCloud, faLink, faAt, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons'; +import { defineComponent, defineAsyncComponent } from 'vue'; +import { faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug } 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 './visibility-chooser.vue'; -import MkUserSelect from './user-select.vue'; import XNotePreview from './note-preview.vue'; import { parse } from '../../mfm/parse'; -import { host, url } from '../config'; +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'; +import { Autocomplete } from '@/scripts/autocomplete'; import { noteVisibilities } from '../../types'; -import { utils } from '@syuilo/aiscript'; +import * as os from '@/os'; +import { selectFile } from '@/scripts/select-file'; +import { notePostInterruptors, postFormActions } from '@/store'; -export default Vue.extend({ +export default defineComponent({ components: { 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) + XPostFormAttaches: defineAsyncComponent(() => import('./post-form-attaches.vue')), + XPollEditor: defineAsyncComponent(() => import('./poll-editor.vue')) }, + inject: ['modal'], + props: { reply: { type: Object, @@ -117,19 +117,22 @@ export default Vue.extend({ type: Boolean, required: false, default: false - } + }, + autofocus: { + type: Boolean, + required: false, + default: true + }, }, + emits: ['posted', 'done', 'esc'], + data() { return { posting: false, text: '', files: [], - uploadings: [], - poll: false, - pollChoices: [], - pollMultiple: false, - pollExpiration: [], + poll: null, useCw: false, cw: null, localOnly: false, @@ -139,7 +142,8 @@ export default Vue.extend({ draghover: false, quoteId: null, recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), - faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faCloud, faLink, faAt, faBiohazard, faPlug + postFormActions, + faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug }; }, @@ -190,7 +194,7 @@ export default Vue.extend({ return !this.posting && (1 <= this.text.length || 1 <= this.files.length || this.poll || this.renote) && (length(this.text.trim()) <= this.max) && - (!this.poll || this.pollChoices.length >= 2); + (!this.poll || this.poll.choices.length >= 2); }, max(): number { @@ -246,14 +250,14 @@ export default Vue.extend({ if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) { this.visibility = this.reply.visibility; if (this.reply.visibility === 'specified') { - this.$root.api('users/show', { + os.api('users/show', { userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$store.state.i.id && uid !== this.reply.userId) }).then(users => { this.visibleUsers.push(...users); }); if (this.reply.userId !== this.$store.state.i.id) { - this.$root.api('users/show', { userId: this.reply.userId }).then(user => { + os.api('users/show', { userId: this.reply.userId }).then(user => { this.visibleUsers.push(user); }); } @@ -271,15 +275,21 @@ export default Vue.extend({ this.cw = this.reply.cw; } - this.focus(); - - this.$nextTick(() => { + if (this.autofocus) { this.focus(); - }); + + this.$nextTick(() => { + this.focus(); + }); + } + + // TODO: detach when unmount + new Autocomplete(this.$refs.text, this, { model: 'text' }); + new Autocomplete(this.$refs.cw, this, { model: 'cw' }); this.$nextTick(() => { // 書きかけの投稿を復元 - if (!this.instant && !this.mention) { + if (!this.instant && !this.mention && !this.specified) { const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey]; if (draft) { this.text = draft.data.text; @@ -289,10 +299,7 @@ export default Vue.extend({ this.localOnly = draft.data.localOnly; this.files = (draft.data.files || []).filter(e => e); if (draft.data.poll) { - this.poll = true; - this.$nextTick(() => { - (this.$refs.poll as any).set(draft.data.poll); - }); + this.poll = draft.data.poll; } } } @@ -305,13 +312,7 @@ export default Vue.extend({ this.cw = init.cw; this.useCw = init.cw != null; if (init.poll) { - this.poll = true; - this.$nextTick(() => { - (this.$refs.poll as any).set({ - choices: init.poll.choices.map(c => c.text), - multiple: init.poll.multiple - }); - }); + this.poll = init.poll; } this.visibility = init.visibility; this.localOnly = init.localOnly; @@ -328,11 +329,24 @@ export default Vue.extend({ this.$watch('useCw', () => this.saveDraft()); this.$watch('cw', () => this.saveDraft()); this.$watch('poll', () => this.saveDraft()); - this.$watch('files', () => this.saveDraft()); + this.$watch('files', () => this.saveDraft(), { deep: true }); this.$watch('visibility', () => this.saveDraft()); this.$watch('localOnly', () => this.saveDraft()); }, + togglePoll() { + if (this.poll) { + this.poll = null; + } else { + this.poll = { + choices: ['', ''], + multiple: false, + expiresAt: null, + expiredAfter: null, + }; + } + }, + trimmedLength(text: string) { return length(text.trim()); }, @@ -346,85 +360,50 @@ export default Vue.extend({ }, 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() { - selectDriveFile(this.$root, true).then(files => { + selectFile(ev.currentTarget || ev.target, this.$t('attachFile'), true).then(files => { for (const file of files) { - this.attachMedia(file); + this.files.push(file); } }); }, - attachMedia(driveFile) { - this.files.push(driveFile); - }, - detachMedia(id) { this.files = this.files.filter(x => x.id != id); }, updateMedia(file) { - Vue.set(this.files, this.files.findIndex(x => x.id === file.id), file); - }, - - onChangeFile() { - for (const x of Array.from((this.$refs.file as any).files)) this.upload(x); + this.files[this.files.findIndex(x => x.id === file.id)] = file; }, upload(file: File, name?: string) { - (this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder, name); - }, - - onChangeUploadings(uploads) { - this.$emit('change-uploadings', uploads); + os.upload(file, this.$store.state.settings.uploadFolder, name).then(res => { + this.files.push(res); + }); }, - onPollUpdate() { - const got = this.$refs.poll.get(); - this.pollChoices = got.choices; - this.pollMultiple = got.multiple; - this.pollExpiration = [got.expiration, got.expiresAt || got.expiredAfter]; + onPollUpdate(poll) { + this.poll = poll; this.saveDraft(); }, - setVisibility() { + async setVisibility() { if (this.channel) { // TODO: information dialog return; } - const w = this.$root.new(MkVisibilityChooser, { - source: this.$refs.visibilityButton, + + os.popup(await import('./visibility-picker.vue'), { currentVisibility: this.visibility, - currentLocalOnly: this.localOnly - }); - w.$once('chosen', ({ visibility, localOnly }) => { - this.applyVisibility(visibility); - this.localOnly = localOnly; - }); + currentLocalOnly: this.localOnly, + src: this.$refs.visibilityButton + }, { + changeVisibility: visibility => { + this.applyVisibility(visibility); + }, + changeLocalOnly: localOnly => { + this.localOnly = localOnly; + } + }, 'closed'); }, applyVisibility(v: string) { @@ -432,8 +411,7 @@ export default Vue.extend({ }, addVisibleUser() { - const vm = this.$root.new(MkUserSelect, {}); - vm.$once('selected', user => { + os.selectUser().then(user => { this.visibleUsers.push(user); }); }, @@ -445,12 +423,13 @@ export default Vue.extend({ clear() { this.text = ''; this.files = []; - this.poll = false; + this.poll = null; this.quoteId = null; }, onKeydown(e) { - if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post(); + if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post(); + if (e.which === 27) this.$emit('esc'); }, async onPaste(e: ClipboardEvent) { @@ -469,7 +448,7 @@ export default Vue.extend({ if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) { e.preventDefault(); - this.$root.dialog({ + os.dialog({ type: 'info', text: this.$t('quoteQuestion'), showCancelButton: true @@ -487,7 +466,7 @@ export default Vue.extend({ 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'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; if (isFile || isDriveFile) { e.preventDefault(); this.draghover = true; @@ -514,7 +493,7 @@ export default Vue.extend({ } //#region ドライブのファイル - const driveFile = e.dataTransfer.getData('mk_drive_file'); + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); if (driveFile != null && driveFile != '') { const file = JSON.parse(driveFile); this.files.push(file); @@ -537,7 +516,7 @@ export default Vue.extend({ visibility: this.visibility, localOnly: this.localOnly, files: this.files, - poll: this.poll && this.$refs.poll ? (this.$refs.poll as any).get() : undefined + poll: this.poll } }; @@ -559,29 +538,30 @@ export default Vue.extend({ replyId: this.reply ? this.reply.id : undefined, renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined, channelId: this.channel ? this.channel.id : undefined, - poll: this.poll ? (this.$refs.poll as any).get() : undefined, + poll: this.poll, cw: this.useCw ? this.cw || '' : undefined, localOnly: this.localOnly, visibility: this.visibility, visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined, - viaMobile: this.$root.isMobile + viaMobile: os.isMobile }; // plugin - if (this.$store.state.notePostInterruptors.length > 0) { - for (const interruptor of this.$store.state.notePostInterruptors) { - data = utils.valToJs(await interruptor.handler(JSON.parse(JSON.stringify(data)))); + if (notePostInterruptors.length > 0) { + for (const interruptor of notePostInterruptors) { + data = await interruptor.handler(JSON.parse(JSON.stringify(data))); } } this.posting = true; - this.$root.api('notes/create', data).then(() => { + os.api('notes/create', data).then(() => { this.clear(); this.deleteDraft(); this.$emit('posted'); }).catch(err => { }).then(() => { this.posting = false; + this.$emit('done'); }); if (this.text && this.text != '') { @@ -592,39 +572,32 @@ export default Vue.extend({ }, cancel() { - this.$emit('cancel'); + this.$emit('done'); }, insertMention() { - const vm = this.$root.new(MkUserSelect, {}); - vm.$once('selected', user => { - insertTextAtCursor(this.$refs.text, getAcct(user) + ' '); + os.selectUser().then(user => { + insertTextAtCursor(this.$refs.text, '@' + getAcct(user) + ' '); }); }, async insertEmoji(ev) { - const vm = this.$root.new(await import('./emoji-picker.vue').then(m => m.default), { - source: ev.currentTarget || ev.target - }).$once('chosen', emoji => { + os.pickEmoji(ev.currentTarget || ev.target).then(emoji => { insertTextAtCursor(this.$refs.text, emoji); - vm.close(); }); }, showActions(ev) { - this.$root.menu({ - items: this.$store.state.postFormActions.map(action => ({ - text: action.title, - action: () => { - action.handler({ - text: this.text - }, (key, value) => { - if (key === 'text') { this.text = value; } - }); - } - })), - source: ev.currentTarget || ev.target, - }); + os.modalMenu(postFormActions.map(action => ({ + text: action.title, + action: () => { + action.handler({ + text: this.text + }, (key, value) => { + if (key === 'text') { this.text = value; } + }); + } + })), ev.currentTarget || ev.target); } } }); @@ -632,26 +605,22 @@ export default Vue.extend({ <style lang="scss" scoped> .gafaadew { - background: var(--panel); + position: relative; + + &.modal { + width: 100%; + max-width: 520px; + } > 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 { @@ -662,10 +631,6 @@ export default Vue.extend({ > .text-count { opacity: 0.7; line-height: 66px; - - @media (max-width: 500px) { - line-height: 50px; - } } > .visibility { @@ -678,8 +643,9 @@ export default Vue.extend({ } } - .local-only { - margin: 0 8px; + > .local-only { + margin: 0 0 0 12px; + opacity: 0.7; } > .submit { @@ -690,10 +656,6 @@ export default Vue.extend({ vertical-align: bottom; border-radius: 4px; - @media (max-width: 500px) { - margin: 8px; - } - &:disabled { opacity: 0.7; } @@ -706,13 +668,6 @@ export default Vue.extend({ } > .form { - max-width: 500px; - margin: 0 auto; - - &.fixed { - max-width: unset; - } - > .preview { padding: 16px; } @@ -741,10 +696,6 @@ export default Vue.extend({ overflow: auto; white-space: nowrap; - @media (max-width: 500px) { - padding: 6px 16px; - } - > .visibleUsers { display: inline; top: -1px; @@ -782,10 +733,6 @@ export default Vue.extend({ color: var(--fg); font-family: inherit; - @media (max-width: 500px) { - padding: 0 16px; - } - &:focus { outline: none; } @@ -806,31 +753,14 @@ export default Vue.extend({ min-width: 100%; min-height: 90px; - @media (max-width: 500px) { - min-height: 80px; - } - &.withCw { padding-top: 8px; } } - > .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; - } - > button { display: inline-block; padding: 0; @@ -850,5 +780,45 @@ export default Vue.extend({ } } } + + &.max-width_500px { + > header { + height: 50px; + + > .cancel { + width: 50px; + line-height: 50px; + } + + > div { + > .text-count { + line-height: 50px; + } + + > .submit { + margin: 8px; + } + } + } + + > .form { + > .to-specified { + padding: 6px 16px; + } + + > .cw, + > .text { + padding: 0 16px; + } + + > .text { + min-height: 80px; + } + + > footer { + padding: 0 8px 8px 8px; + } + } + } } </style> diff --git a/src/client/components/reaction-icon.vue b/src/client/components/reaction-icon.vue index fe2b528368..f781f63d79 100644 --- a/src/client/components/reaction-icon.vue +++ b/src/client/components/reaction-icon.vue @@ -1,10 +1,11 @@ <template> -<mk-emoji :emoji="reaction.startsWith(':') ? null : reaction" :name="reaction.startsWith(':') ? reaction.substr(1, reaction.length - 2) : null" :customEmojis="customEmojis" :is-reaction="true" :normal="true" :no-style="noStyle"/> +<MkEmoji :emoji="reaction.startsWith(':') ? null : reaction" :name="reaction.startsWith(':') ? reaction.substr(1, reaction.length - 2) : null" :customEmojis="customEmojis" :is-reaction="true" :normal="true" :no-style="noStyle"/> </template> <script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ +import { defineComponent } from 'vue';import * as os from '@/os'; + +export default defineComponent({ props: { reaction: { type: String, diff --git a/src/client/components/reaction-picker.vue b/src/client/components/reaction-picker.vue index cf5de327a3..fca1e858bb 100644 --- a/src/client/components/reaction-picker.vue +++ b/src/client/components/reaction-picker.vue @@ -1,31 +1,28 @@ <template> -<x-popup :source="source" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }" v-hotkey.global="keymap"> - <div class="rdfaahpb"> +<MkModal ref="modal" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')"> + <div class="rdfaahpb _popup" v-hotkey="keymap"> <div class="buttons" ref="buttons" :class="{ showFocus }"> - <button class="_button" v-for="(reaction, i) in rs" :key="reaction" @click="react(reaction)" :tabindex="i + 1" :title="reaction" v-particle><x-reaction-icon :reaction="reaction"/></button> + <button class="_button" v-for="(reaction, i) in rs" :key="reaction" @click="react(reaction)" :tabindex="i + 1" :title="reaction" v-particle><XReactionIcon :reaction="reaction"/></button> </div> - <input class="text" v-model.trim="text" :placeholder="$t('enterEmoji')" @keyup.enter="reactText" @input="tryReactText" v-autocomplete="{ model: 'text' }"> + <input class="text" ref="text" v-model.trim="text" :placeholder="$t('enterEmoji')" @keyup.enter="reactText" @input="tryReactText"> </div> -</x-popup> +</MkModal> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { emojiRegex } from '../../misc/emoji-regex'; -import XReactionIcon from './reaction-icon.vue'; -import XPopup from './popup.vue'; +import XReactionIcon from '@/components/reaction-icon.vue'; +import MkModal from '@/components/ui/modal.vue'; +import { Autocomplete } from '@/scripts/autocomplete'; -export default Vue.extend({ +export default defineComponent({ components: { - XPopup, XReactionIcon, + MkModal, }, props: { - source: { - required: true - }, - reactions: { required: false }, @@ -35,8 +32,14 @@ export default Vue.extend({ required: false, default: false }, + + src: { + required: false + }, }, + emits: ['done', 'closed'], + data() { return { rs: this.reactions || this.$store.state.settings.reactions, @@ -70,21 +73,30 @@ export default Vue.extend({ watch: { focus(i) { - this.$refs.buttons.children[i].focus(); + this.$refs.buttons.children[i].focus({ + preventScroll: true + }); } }, mounted() { - this.focus = 0; + this.$nextTick(() => { + this.focus = 0; + }); + + // TODO: detach when unmount + new Autocomplete(this.$refs.text, this, { model: 'text' }); }, methods: { close() { - this.$refs.popup.close(); + this.$emit('done'); + this.$refs.modal.close(); }, react(reaction) { - this.$emit('chosen', reaction); + this.$emit('done', reaction); + this.$refs.modal.close(); }, reactText() { @@ -136,6 +148,7 @@ export default Vue.extend({ &.showFocus { > button:focus { + position: relative; z-index: 1; &:after { diff --git a/src/client/components/reactions-viewer.details.vue b/src/client/components/reactions-viewer.details.vue index 96d1408fc1..e6d541e7f0 100644 --- a/src/client/components/reactions-viewer.details.vue +++ b/src/client/components/reactions-viewer.details.vue @@ -1,28 +1,36 @@ <template> -<mk-tooltip :source="source" ref="tooltip"> - <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> -</mk-tooltip> +<MkTooltip :source="source" ref="tooltip" @closed="$emit('closed')"> + <div class="bqxuuuey"> + <div class="info"> + <div>{{ reaction.replace('@.', '') }}</div> + <XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon"/> + </div> + <template v-if="users.length <= 10"> + <b v-for="u in users" :key="u.id" style="margin-right: 12px;"> + <MkAvatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/> + <MkUserName :user="u" :nowrap="false" style="line-height: 24px;"/> + </b> + </template> + <template v-if="10 < users.length"> + <b v-for="u in users" :key="u.id" style="margin-right: 12px;"> + <MkAvatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/> + <MkUserName :user="u" :nowrap="false" style="line-height: 24px;"/> + </b> + <span slot="omitted">+{{ count - 10 }}</span> + </template> + </div> +</MkTooltip> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import MkTooltip from './ui/tooltip.vue'; +import XReactionIcon from './reaction-icon.vue'; -export default Vue.extend({ +export default defineComponent({ components: { - MkTooltip + MkTooltip, + XReactionIcon }, props: { reaction: { @@ -37,15 +45,30 @@ export default Vue.extend({ type: Number, required: true, }, + emojis: { + type: Array, + required: true, + }, source: { required: true, } }, + emits: ['closed'], +}) +</script> + +<style lang="scss" scoped> +.bqxuuuey { + > .info { + padding: 0 0 8px 0; + text-align: center; - methods: { - close() { - this.$refs.tooltip.close(); + > .icon { + display: block; + width: 60px; + height: 60px; + margin: 0 auto; } } -}) -</script> +} +</style> diff --git a/src/client/components/reactions-viewer.reaction.vue b/src/client/components/reactions-viewer.reaction.vue index 763f4e9e9a..62128d7e66 100644 --- a/src/client/components/reactions-viewer.reaction.vue +++ b/src/client/components/reactions-viewer.reaction.vue @@ -4,24 +4,25 @@ :class="{ reacted: note.myReaction == reaction, canToggle }" @click="toggleReaction(reaction)" v-if="count > 0" - @touchstart="onMouseover" + @touchstart.passive="onMouseover" @mouseover="onMouseover" @mouseleave="onMouseleave" @touchend="onMouseleave" ref="reaction" v-particle="canToggle" > - <x-reaction-icon :reaction="reaction" :custom-emojis="note.emojis" ref="icon"/> + <XReactionIcon :reaction="reaction" :custom-emojis="note.emojis"/> <span>{{ count }}</span> </button> </template> <script lang="ts"> -import Vue from 'vue'; -import XDetails from './reactions-viewer.details.vue'; -import XReactionIcon from './reaction-icon.vue'; +import { defineComponent, ref } from 'vue'; +import XDetails from '@/components/reactions-viewer.details.vue'; +import XReactionIcon from '@/components/reaction-icon.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XReactionIcon }, @@ -45,7 +46,7 @@ export default Vue.extend({ }, data() { return { - details: null, + close: null, detailsTimeoutId: null, isHovering: false }; @@ -58,7 +59,7 @@ export default Vue.extend({ watch: { count(newCount, oldCount) { if (oldCount < newCount) this.anime(); - if (this.details != null) this.openDetails(); + if (this.close != null) this.openDetails(); }, }, mounted() { @@ -70,18 +71,18 @@ export default Vue.extend({ const oldReaction = this.note.myReaction; if (oldReaction) { - this.$root.api('notes/reactions/delete', { + os.api('notes/reactions/delete', { noteId: this.note.id }).then(() => { if (oldReaction !== this.reaction) { - this.$root.api('notes/reactions/create', { + os.api('notes/reactions/create', { noteId: this.note.id, reaction: this.reaction }); } }); } else { - this.$root.api('notes/reactions/create', { + os.api('notes/reactions/create', { noteId: this.note.id, reaction: this.reaction }); @@ -99,7 +100,7 @@ export default Vue.extend({ this.closeDetails(); }, openDetails() { - this.$root.api('notes/reactions', { + os.api('notes/reactions', { noteId: this.note.id, type: this.reaction, limit: 11 @@ -110,18 +111,26 @@ export default Vue.extend({ this.closeDetails(); if (!this.isHovering) return; - this.details = this.$root.new(XDetails, { + + const showing = ref(true); + os.popup(XDetails, { + showing, reaction: this.reaction, + emojis: this.note.emojis, users, count: this.count, source: this.$refs.reaction - }); + }, {}, 'closed'); + + this.close = () => { + showing.value = false; + }; }); }, closeDetails() { - if (this.details != null) { - this.details.close(); - this.details = null; + if (this.close != null) { + this.close(); + this.close = null; } }, anime() { diff --git a/src/client/components/reactions-viewer.vue b/src/client/components/reactions-viewer.vue index 88e7df4646..df10294d04 100644 --- a/src/client/components/reactions-viewer.vue +++ b/src/client/components/reactions-viewer.vue @@ -1,28 +1,28 @@ <template> <div class="tdflqwzn" :class="{ isMe }"> - <x-reaction v-for="(count, reaction) in note.reactions" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note" :key="reaction"/> + <XReaction v-for="(count, reaction) in note.reactions" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note" :key="reaction"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import XReaction from './reactions-viewer.reaction.vue'; -export default Vue.extend({ +export default defineComponent({ components: { XReaction }, - data() { - return { - initialReactions: new Set(Object.keys(this.note.reactions)) - }; - }, props: { note: { type: Object, required: true }, }, + data() { + return { + initialReactions: new Set(Object.keys(this.note.reactions)) + }; + }, computed: { isMe(): boolean { return this.$store.getters.isSignedIn && this.$store.state.i.id === this.note.userId; diff --git a/src/client/components/remote-caution.vue b/src/client/components/remote-caution.vue index 21af9f766a..fe65bcd9cc 100644 --- a/src/client/components/remote-caution.vue +++ b/src/client/components/remote-caution.vue @@ -1,12 +1,13 @@ <template> -<div class="jmgmzlwq _panel"><fa :icon="faExclamationTriangle" style="margin-right: 8px;"/>{{ $t('remoteUserCaution') }}<a :href="href" rel="nofollow noopener" target="_blank">{{ $t('showOnRemote') }}</a></div> +<div class="jmgmzlwq _panel"><Fa :icon="faExclamationTriangle" style="margin-right: 8px;"/>{{ $t('remoteUserCaution') }}<a :href="href" rel="nofollow noopener" target="_blank">{{ $t('showOnRemote') }}</a></div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { href: { type: String, diff --git a/src/client/components/sidebar.vue b/src/client/components/sidebar.vue index 77e9af27e5..7548b136ea 100644 --- a/src/client/components/sidebar.vue +++ b/src/client/components/sidebar.vue @@ -4,7 +4,7 @@ <div class="nav-back _modalBg" v-if="showing" @click="showing = false" - @touchstart="showing = false" + @touchstart.passive="showing = false" ></div> </transition> @@ -12,31 +12,31 @@ <nav class="nav" :class="{ iconOnly, hidden }" v-show="showing"> <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"/> + <MkAvatar :user="$store.state.i" class="avatar"/><MkAcct class="text" :user="$store.state.i"/> </button> <button class="item _button index active" @click="top()" v-if="$route.name === 'index'"> - <fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span> + <Fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span> </button> <router-link class="item index" active-class="active" to="/" exact v-else> - <fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span> + <Fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span> </router-link> <template v-for="item in menu"> <div v-if="item === '-'" class="divider"></div> - <component v-else-if="menuDef[item] && (menuDef[item].show !== false)" :is="menuDef[item].to ? 'router-link' : 'button'" class="item _button" :class="item" active-class="active" @click="() => { if (menuDef[item].action) menuDef[item].action() }" :to="menuDef[item].to"> - <fa :icon="menuDef[item].icon" fixed-width/><span class="text">{{ $t(menuDef[item].title) }}</span> - <i v-if="menuDef[item].indicated"><fa :icon="faCircle"/></i> + <component v-else-if="menuDef[item] && (menuDef[item].show !== false)" :is="menuDef[item].to ? 'router-link' : 'button'" class="item _button" :class="item" active-class="active" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}" :to="menuDef[item].to"> + <Fa :icon="menuDef[item].icon" fixed-width/><span class="text">{{ $t(menuDef[item].title) }}</span> + <i v-if="menuDef[item].indicated"><Fa :icon="faCircle"/></i> </component> </template> <div class="divider"></div> <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> + <Fa :icon="faServer" fixed-width/><span class="text">{{ $t('instance') }}</span> </button> <button class="item _button" @click="more"> - <fa :icon="faEllipsisH" fixed-width/><span class="text">{{ $t('more') }}</span> - <i v-if="otherNavItemIndicated"><fa :icon="faCircle"/></i> + <Fa :icon="faEllipsisH" fixed-width/><span class="text">{{ $t('more') }}</span> + <i v-if="otherNavItemIndicated"><Fa :icon="faCircle"/></i> </button> - <router-link class="item" active-class="active" to="/preferences"> - <fa :icon="faCog" fixed-width/><span class="text">{{ $t('settings') }}</span> + <router-link class="item" active-class="active" to="/settings"> + <Fa :icon="faCog" fixed-width/><span class="text">{{ $t('settings') }}</span> </router-link> </div> </nav> @@ -45,13 +45,15 @@ </template> <script lang="ts"> -import Vue from 'vue'; -import { faGripVertical, 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, faServer, faInfoCircle, faQuestionCircle, faProjectDiagram } from '@fortawesome/free-solid-svg-icons'; +import { defineComponent } from 'vue'; +import { faGripVertical, 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, faServer, faInfoCircle, faQuestionCircle, faProjectDiagram, faStream } from '@fortawesome/free-solid-svg-icons'; import { faBell, faEnvelope, faLaugh, faComments } from '@fortawesome/free-regular-svg-icons'; -import { host, instanceName } from '../config'; -import { search } from '../scripts/search'; +import { host, instanceName } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import { sidebarDef } from '@/sidebar'; -export default Vue.extend({ +export default defineComponent({ data() { return { host: host, @@ -59,9 +61,7 @@ export default Vue.extend({ searching: false, accounts: [], connection: null, - menuDef: this.$store.getters.nav({ - search: this.search - }), + menuDef: sidebarDef, iconOnly: false, hidden: false, faGripVertical, 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, faProjectDiagram @@ -127,7 +127,7 @@ export default Vue.extend({ search() { if (this.searching) return; - this.$root.dialog({ + os.dialog({ title: this.$t('search'), input: true }).then(async ({ canceled, result: query }) => { @@ -141,7 +141,7 @@ export default Vue.extend({ }, 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 accounts = (await os.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', @@ -149,96 +149,80 @@ export default Vue.extend({ 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('accountSettings'), - to: '/my/settings', - icon: faCog, - }, null, ...accountItems, { - icon: faPlus, - text: this.$t('addAcount'), - action: () => { - this.$root.menu({ - items: [{ - text: this.$t('existingAcount'), - action: () => { this.addAcount(); }, - }, { - text: this.$t('createAccount'), - action: () => { this.createAccount(); }, - }], - align: 'left', - fixed: true, - width: 240, - source: ev.currentTarget || ev.target, - }); - }, - }]], - align: 'left', - fixed: true, - width: 240, - source: ev.currentTarget || ev.target, + os.modalMenu([...[{ + type: 'link', + text: this.$t('profile'), + to: `/@${ this.$store.state.i.username }`, + avatar: this.$store.state.i, + }, null, ...accountItems, { + icon: faPlus, + text: this.$t('addAcount'), + action: () => { + os.modalMenu([{ + text: this.$t('existingAcount'), + action: () => { this.addAcount(); }, + }, { + text: this.$t('createAccount'), + action: () => { this.createAccount(); }, + }], ev.currentTarget || ev.target); + }, + }]], ev.currentTarget || ev.target, { + align: 'left' }); }, oepnInstanceMenu(ev) { - this.$root.menu({ - items: [{ - type: 'link', - text: this.$t('dashboard'), - to: '/instance', - icon: faTachometerAlt, - }, null, { - type: 'link', - text: this.$t('settings'), - to: '/instance/settings', - icon: faCog, - }, { - 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('jobQueue'), - to: '/instance/queue', - icon: faExchangeAlt, - }, { - type: 'link', - text: this.$t('federation'), - to: '/instance/federation', - icon: faGlobe, - }, { - type: 'link', - text: this.$t('relays'), - to: '/instance/relays', - icon: faProjectDiagram, - }, { - type: 'link', - text: this.$t('announcements'), - to: '/instance/announcements', - icon: faBroadcastTower, - }], - align: 'left', - fixed: true, - width: 200, - source: ev.currentTarget || ev.target, - }); + os.modalMenu([{ + type: 'link', + text: this.$t('dashboard'), + to: '/instance', + icon: faTachometerAlt, + }, null, this.$store.state.i.isAdmin ? { + type: 'link', + text: this.$t('settings'), + to: '/instance/settings', + icon: faCog, + } : undefined, { + 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('jobQueue'), + to: '/instance/queue', + icon: faExchangeAlt, + }, { + type: 'link', + text: this.$t('federation'), + to: '/instance/federation', + icon: faGlobe, + }, { + type: 'link', + text: this.$t('relays'), + to: '/instance/relays', + icon: faProjectDiagram, + }, { + type: 'link', + text: this.$t('announcements'), + to: '/instance/announcements', + icon: faBroadcastTower, + }, { + type: 'link', + text: this.$t('logs'), + to: '/instance/logs', + icon: faStream, + }], ev.currentTarget || ev.target); }, more(ev) { @@ -250,45 +234,40 @@ export default Vue.extend({ action: def.action, indicate: def.indicated, })); - this.$root.menu({ - items: [...items, null, { - type: 'link', - text: this.$t('help'), - to: '/docs', - icon: faQuestionCircle, - }, { - type: 'link', - text: this.$t('aboutX', { x: instanceName || host }), - to: '/about', - icon: faInfoCircle, - }, { - type: 'link', - text: this.$t('aboutMisskey'), - to: '/about-misskey', - icon: faInfoCircle, - }], - align: 'left', - fixed: true, - width: 200, - source: ev.currentTarget || ev.target, - }); + os.modalMenu([...items, null, { + type: 'link', + text: this.$t('help'), + to: '/docs', + icon: faQuestionCircle, + }, { + type: 'link', + text: this.$t('aboutX', { x: instanceName || host }), + to: '/about', + icon: faInfoCircle, + }, { + type: 'link', + text: this.$t('aboutMisskey'), + to: '/about-misskey', + icon: faInfoCircle, + }], ev.currentTarget || ev.target); }, async addAcount() { - this.$root.new(await import('./signin-dialog.vue').then(m => m.default)).$once('login', res => { - this.$store.dispatch('addAcount', res); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }); + os.popup(await import('./signin-dialog.vue'), {}, { + done: res => { + this.$store.dispatch('addAcount', res); + os.success(); + }, + }, 'closed'); }, async createAccount() { - this.$root.new(await import('./signup-dialog.vue').then(m => m.default)).$once('signup', res => { - this.$store.dispatch('addAcount', res); - this.switchAccountWithToken(res.i); - }); + os.popup(await import('./signup-dialog.vue'), {}, { + done: res => { + this.$store.dispatch('addAcount', res); + this.switchAccountWithToken(res.i); + }, + }, 'closed'); }, async switchAccount(account: any) { @@ -297,12 +276,9 @@ export default Vue.extend({ }, switchAccountWithToken(token: string) { - this.$root.dialog({ - type: 'waiting', - iconOnly: true - }); + os.waiting(); - this.$root.api('i', {}, token).then((i: any) => { + os.api('i', {}, token).then((i: any) => { this.$store.dispatch('switchAccount', { ...i, token: token @@ -324,7 +300,7 @@ export default Vue.extend({ 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-enter-from, .nav-leave-active { opacity: 0; transform: translateX(-240px); @@ -335,7 +311,7 @@ export default Vue.extend({ opacity: 1; transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); } -.nav-back-enter, +.nav-back-enter-from, .nav-back-leave-active { opacity: 0; } diff --git a/src/client/components/signin-dialog.vue b/src/client/components/signin-dialog.vue index 98b75e627c..3a820c8f96 100644 --- a/src/client/components/signin-dialog.vue +++ b/src/client/components/signin-dialog.vue @@ -1,19 +1,27 @@ <template> -<x-window ref="window" @closed="() => { $emit('closed'); destroyDom(); }"> +<XModalWindow ref="dialog" + :width="370" + :height="400" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> <template #header>{{ $t('login') }}</template> - <mk-signin :auto-set="autoSet" @login="onLogin"/> -</x-window> + + <div class="_section"> + <MkSignin :auto-set="autoSet" @login="onLogin"/> + </div> +</XModalWindow> </template> <script lang="ts"> -import Vue from 'vue'; -import XWindow from './window.vue'; +import { defineComponent } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; import MkSignin from './signin.vue'; -export default Vue.extend({ +export default defineComponent({ components: { MkSignin, - XWindow, + XModalWindow, }, props: { @@ -24,10 +32,12 @@ export default Vue.extend({ } }, + emits: ['done', 'closed'], + methods: { onLogin(res) { - this.$emit('login', res); - this.$refs.window.close(); + this.$emit('done', res); + this.$refs.dialog.close(); } } }); diff --git a/src/client/components/signin.vue b/src/client/components/signin.vue index a7653b17b0..b02ef2e940 100755 --- a/src/client/components/signin.vue +++ b/src/client/components/signin.vue @@ -2,57 +2,58 @@ <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"> - <mk-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange"> + <MkInput v-model:value="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @update:value="onUsernameChange"> <span>{{ $t('username') }}</span> <template #prefix>@</template> <template #suffix>@{{ host }}</template> - </mk-input> - <mk-input v-model="password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required> + </MkInput> + <MkInput v-model:value="password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required> <span>{{ $t('password') }}</span> - <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> - <a class="_panel _button" style="margin: 8px auto;" v-if="meta && meta.enableTwitterIntegration" :href="`${apiUrl}/signin/twitter`"><fa :icon="faTwitter" style="margin-right: 4px;"/>{{ $t('signinWith', { x: 'Twitter' }) }}</a> - <a class="_panel _button" style="margin: 8px auto;" v-if="meta && meta.enableGithubIntegration" :href="`${apiUrl}/signin/github`"><fa :icon="faGithub" style="margin-right: 4px;"/>{{ $t('signinWith', { x: 'GitHub' }) }}</a> - <a class="_panel _button" style="margin: 8px auto;" v-if="meta && meta.enableDiscordIntegration" :href="`${apiUrl}/signin/discord`"><fa :icon="faDiscord" style="margin-right: 4px;"/>{{ $t('signinWith', { x: 'Discord' }) }}</a> + <template #prefix><Fa :icon="faLock"/></template> + </MkInput> + <MkButton type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? $t('loggingIn') : $t('login') }}</MkButton> + <a class="_panelButton" style="margin: 8px auto;" v-if="meta && meta.enableTwitterIntegration" :href="`${apiUrl}/signin/twitter`"><Fa :icon="faTwitter" style="margin-right: 4px;"/>{{ $t('signinWith', { x: 'Twitter' }) }}</a> + <a class="_panelButton" style="margin: 8px auto;" v-if="meta && meta.enableGithubIntegration" :href="`${apiUrl}/signin/github`"><Fa :icon="faGithub" style="margin-right: 4px;"/>{{ $t('signinWith', { x: 'GitHub' }) }}</a> + <a class="_panelButton" style="margin: 8px auto;" v-if="meta && meta.enableDiscordIntegration" :href="`${apiUrl}/signin/discord`"><Fa :icon="faDiscord" style="margin-right: 4px;"/>{{ $t('signinWith', { x: 'Discord' }) }}</a> </div> <div class="2fa-signin" v-if="totpLogin" :class="{ securityKeys: user && user.securityKeys }"> <div v-if="user && user.securityKeys" class="twofa-group tap-group"> <p>{{ $t('tapSecurityKey') }}</p> - <mk-button @click="queryKey" v-if="!queryingKey"> + <MkButton @click="queryKey" v-if="!queryingKey"> {{ $t('retry') }} - </mk-button> + </MkButton> </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('twoStepAuthentication') }}</p> - <mk-input v-model="password" type="password" :with-password-toggle="true" v-if="user && user.usePasswordLessLogin" required> + <MkInput v-model:value="password" type="password" :with-password-toggle="true" v-if="user && user.usePasswordLessLogin" required> <span>{{ $t('password') }}</span> - <template #prefix><fa :icon="faLock"/></template> - </mk-input> - <mk-input v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required> + <template #prefix><Fa :icon="faLock"/></template> + </MkInput> + <MkInput v-model:value="token" type="text" 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> + <template #prefix><Fa :icon="faGavel"/></template> + </MkInput> + <MkButton type="submit" :disabled="signing" primary style="margin: 0 auto;">{{ signing ? $t('loggingIn') : $t('login') }}</MkButton> </div> </div> </form> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { toUnicode } from 'punycode'; import { faLock, faGavel } from '@fortawesome/free-solid-svg-icons'; import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons'; import MkButton from './ui/button.vue'; import MkInput from './ui/input.vue'; -import { apiUrl, host } from '../config'; -import { byteify, hexify } from '../scripts/2fa'; +import { apiUrl, host } from '@/config'; +import { byteify, hexify } from '@/scripts/2fa'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkButton, MkInput, @@ -71,6 +72,8 @@ export default Vue.extend({ } }, + emits: ['login'], + data() { return { signing: false, @@ -94,18 +97,9 @@ export default Vue.extend({ }, }, - created() { - if (this.autoSet) { - this.$once('login', res => { - localStorage.setItem('i', res.i); - location.reload(); - }); - } - }, - methods: { onUsernameChange() { - this.$root.api('users/show', { + os.api('users/show', { username: this.username }).then(user => { this.user = user; @@ -114,6 +108,13 @@ export default Vue.extend({ }); }, + onLogin(res) { + if (this.autoSet) { + localStorage.setItem('i', res.i); + location.reload(); + } + }, + queryKey() { this.queryingKey = true; return navigator.credentials.get({ @@ -132,7 +133,7 @@ export default Vue.extend({ }).then(credential => { this.queryingKey = false; this.signing = true; - return this.$root.api('signin', { + return os.api('signin', { username: this.username, password: this.password, signature: hexify(credential.response.signature), @@ -143,9 +144,10 @@ export default Vue.extend({ }); }).then(res => { this.$emit('login', res); + this.onLogin(res); }).catch(err => { if (err === null) return; - this.$root.dialog({ + os.dialog({ type: 'error', text: this.$t('signinFailed') }); @@ -157,7 +159,7 @@ export default Vue.extend({ this.signing = true; if (!this.totpLogin && this.user && this.user.twoFactorEnabled) { if (window.PublicKeyCredential && this.user.securityKeys) { - this.$root.api('signin', { + os.api('signin', { username: this.username, password: this.password }).then(res => { @@ -166,7 +168,7 @@ export default Vue.extend({ this.challengeData = res; return this.queryKey(); }).catch(() => { - this.$root.dialog({ + os.dialog({ type: 'error', text: this.$t('signinFailed') }); @@ -179,14 +181,15 @@ export default Vue.extend({ this.signing = false; } } else { - this.$root.api('signin', { + os.api('signin', { username: this.username, password: this.password, token: this.user && this.user.twoFactorEnabled ? this.token : undefined }).then(res => { this.$emit('login', res); + this.onLogin(res); }).catch(() => { - this.$root.dialog({ + os.dialog({ type: 'error', text: this.$t('loginFailed') }); diff --git a/src/client/components/signup-dialog.vue b/src/client/components/signup-dialog.vue index eff1f79c48..eb0c40523b 100644 --- a/src/client/components/signup-dialog.vue +++ b/src/client/components/signup-dialog.vue @@ -1,19 +1,27 @@ <template> -<x-window ref="window" :width="366" :height="506" @closed="() => { $emit('closed'); destroyDom(); }"> +<XModalWindow ref="dialog" + :width="366" + :height="500" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> <template #header>{{ $t('signup') }}</template> - <x-signup :auto-set="autoSet" @signup="onSignup"/> -</x-window> + + <div class="_section"> + <XSignup :auto-set="autoSet" @signup="onSignup"/> + </div> +</XModalWindow> </template> <script lang="ts"> -import Vue from 'vue'; -import XWindow from './window.vue'; +import { defineComponent } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; import XSignup from './signup.vue'; -export default Vue.extend({ +export default defineComponent({ components: { XSignup, - XWindow, + XModalWindow, }, props: { @@ -24,10 +32,12 @@ export default Vue.extend({ } }, + emits: ['done', 'closed'], + methods: { onSignup(res) { - this.$emit('signup', res); - this.$refs.window.close(); + this.$emit('done', res); + this.$refs.dialog.close(); } } }); diff --git a/src/client/components/signup.vue b/src/client/components/signup.vue index ff1932b42d..3337330430 100644 --- a/src/client/components/signup.vue +++ b/src/client/components/signup.vue @@ -1,71 +1,82 @@ <template> <form class="mk-signup" @submit.prevent="onSubmit" :autocomplete="Math.random()"> <template v-if="meta"> - <mk-input v-if="meta.disableRegistration" v-model="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required> + <MkInput v-if="meta.disableRegistration" v-model:value="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required> <span>{{ $t('invitationCode') }}</span> - <template #prefix><fa :icon="faKey"/></template> - </mk-input> - <mk-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @input="onChangeUsername"> + <template #prefix><Fa :icon="faKey"/></template> + </MkInput> + <MkInput v-model:value="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @update:value="onChangeUsername"> <span>{{ $t('username') }}</span> <template #prefix>@</template> <template #suffix>@{{ host }}</template> <template #desc> - <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('usernameInvalidFormat') }}</span> - <span v-if="usernameState == 'min-range'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('tooShort') }}</span> - <span v-if="usernameState == 'max-range'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('tooLong') }}</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('usernameInvalidFormat') }}</span> + <span v-if="usernameState == 'min-range'" style="color:#FF1161"><Fa :icon="faExclamationTriangle" fixed-width/> {{ $t('tooShort') }}</span> + <span v-if="usernameState == 'max-range'" style="color:#FF1161"><Fa :icon="faExclamationTriangle" fixed-width/> {{ $t('tooLong') }}</span> </template> - </mk-input> - <mk-input v-model="password" type="password" :autocomplete="Math.random()" required @input="onChangePassword"> + </MkInput> + <MkInput v-model:value="password" type="password" :autocomplete="Math.random()" required @update:value="onChangePassword"> <span>{{ $t('password') }}</span> - <template #prefix><fa :icon="faLock"/></template> + <template #prefix><Fa :icon="faLock"/></template> <template #desc> - <p v-if="passwordStrength == 'low'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('weakPassword') }}</p> - <p v-if="passwordStrength == 'medium'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('normalPassword') }}</p> - <p v-if="passwordStrength == 'high'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('strongPassword') }}</p> + <p v-if="passwordStrength == 'low'" style="color:#FF1161"><Fa :icon="faExclamationTriangle" fixed-width/> {{ $t('weakPassword') }}</p> + <p v-if="passwordStrength == 'medium'" style="color:#3CB7B5"><Fa :icon="faCheck" fixed-width/> {{ $t('normalPassword') }}</p> + <p v-if="passwordStrength == 'high'" style="color:#3CB7B5"><Fa :icon="faCheck" fixed-width/> {{ $t('strongPassword') }}</p> </template> - </mk-input> - <mk-input v-model="retypedPassword" type="password" :autocomplete="Math.random()" required @input="onChangePasswordRetype"> + </MkInput> + <MkInput v-model:value="retypedPassword" type="password" :autocomplete="Math.random()" required @update:value="onChangePasswordRetype"> <span>{{ $t('password') }} ({{ $t('retype') }})</span> - <template #prefix><fa :icon="faLock"/></template> + <template #prefix><Fa :icon="faLock"/></template> <template #desc> - <p v-if="passwordRetypeState == 'match'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('passwordMatched') }}</p> - <p v-if="passwordRetypeState == 'not-match'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('passwordNotMatched') }}</p> + <p v-if="passwordRetypeState == 'match'" style="color:#3CB7B5"><Fa :icon="faCheck" fixed-width/> {{ $t('passwordMatched') }}</p> + <p v-if="passwordRetypeState == 'not-match'" style="color:#FF1161"><Fa :icon="faExclamationTriangle" fixed-width/> {{ $t('passwordNotMatched') }}</p> </template> - </mk-input> - <mk-switch v-model="ToSAgreement" v-if="meta.tosUrl"> - <i18n path="agreeTo"> + </MkInput> + <MkSwitch v-model:value="ToSAgreement" v-if="meta.tosUrl"> + <i18n-t keypath="agreeTo"> <a :href="meta.tosUrl" class="_link" target="_blank">{{ $t('tos') }}</a> - </i18n> - </mk-switch> - <captcha v-if="meta.enableHcaptcha" class="captcha" provider="hcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/> - <captcha v-if="meta.enableRecaptcha" class="captcha" provider="grecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :sitekey="meta.recaptchaSiteKey"/> - <mk-button type="submit" :disabled="shouldDisableSubmitting" primary>{{ $t('start') }}</mk-button> + </i18n-t> + </MkSwitch> + <captcha v-if="meta.enableHcaptcha" class="captcha" provider="hcaptcha" ref="hcaptcha" v-model:value="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/> + <captcha v-if="meta.enableRecaptcha" class="captcha" provider="grecaptcha" ref="recaptcha" v-model:value="reCaptchaResponse" :sitekey="meta.recaptchaSiteKey"/> + <MkButton type="submit" :disabled="shouldDisableSubmitting" primary>{{ $t('start') }}</MkButton> </template> </form> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent, defineAsyncComponent } from 'vue'; import { faLock, faExclamationTriangle, faSpinner, faCheck, faKey } from '@fortawesome/free-solid-svg-icons'; const getPasswordStrength = require('syuilo-password-strength'); import { toUnicode } from 'punycode'; -import { host, url } from '../config'; +import { host, url } from '@/config'; import MkButton from './ui/button.vue'; import MkInput from './ui/input.vue'; import MkSwitch from './ui/switch.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkButton, MkInput, MkSwitch, - captcha: () => import('./captcha.vue').then(x => x.default), + captcha: defineAsyncComponent(() => import('./captcha.vue')), }, + props: { + autoSet: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['signup'], + data() { return { host: toUnicode(host), @@ -85,14 +96,6 @@ export default Vue.extend({ } }, - props: { - autoSet: { - type: Boolean, - required: false, - default: false, - } - }, - computed: { meta() { return this.$store.state.instance.meta; @@ -114,15 +117,6 @@ export default Vue.extend({ } }, - created() { - if (this.autoSet) { - this.$once('signup', res => { - localStorage.setItem('i', res.i); - location.reload(); - }); - } - }, - methods: { onChangeUsername() { if (this.username == '') { @@ -143,7 +137,7 @@ export default Vue.extend({ this.usernameState = 'wait'; - this.$root.api('username/available', { + os.api('username/available', { username: this.username }).then(result => { this.usernameState = result.available ? 'ok' : 'unavailable'; @@ -175,27 +169,32 @@ export default Vue.extend({ if (this.submitting) return; this.submitting = true; - this.$root.api('signup', { + os.api('signup', { username: this.username, password: this.password, invitationCode: this.invitationCode, 'hcaptcha-response': this.hCaptchaResponse, 'g-recaptcha-response': this.reCaptchaResponse, }).then(() => { - this.$root.api('signin', { + os.api('signin', { username: this.username, password: this.password }).then(res => { this.$emit('signup', res); + + if (this.autoSet) { + localStorage.setItem('i', res.i); + location.reload(); + } }); }).catch(() => { this.submitting = false; this.$refs.hcaptcha?.reset?.(); this.$refs.recaptcha?.reset?.(); - this.$root.dialog({ + os.dialog({ type: 'error', - text: this.$t('error') + text: this.$t('somethingHappened') }); }); } @@ -205,8 +204,6 @@ export default Vue.extend({ <style lang="scss" scoped> .mk-signup { - padding: 32px 0 0; - .captcha { margin: 16px 0; } diff --git a/src/client/components/stream-indicator.vue b/src/client/components/stream-indicator.vue index 9005cb9a8b..7b020171a4 100644 --- a/src/client/components/stream-indicator.vue +++ b/src/client/components/stream-indicator.vue @@ -9,9 +9,10 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ data() { return { hasDisconnected: false, @@ -19,14 +20,14 @@ export default Vue.extend({ }, computed: { stream() { - return this.$root.stream; + return os.stream; }, }, created() { - this.$root.stream.on('_disconnected_', this.onDisconnected); + os.stream.on('_disconnected_', this.onDisconnected); }, - beforeDestroy() { - this.$root.stream.off('_disconnected_', this.onDisconnected); + beforeUnmount() { + os.stream.off('_disconnected_', this.onDisconnected); }, methods: { onDisconnected() { diff --git a/src/client/components/sub-note-content.vue b/src/client/components/sub-note-content.vue index a14c832ea8..0bef072fe4 100644 --- a/src/client/components/sub-note-content.vue +++ b/src/client/components/sub-note-content.vue @@ -3,28 +3,29 @@ <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="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"/> + <XMediaList :media-list="note.files"/> </details> <details v-if="note.poll"> <summary>{{ $t('poll') }}</summary> - <x-poll :note="note"/> + <XPoll :note="note"/> </details> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faReply } from '@fortawesome/free-solid-svg-icons'; import XPoll from './poll.vue'; import XMediaList from './media-list.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XPoll, XMediaList, diff --git a/src/client/components/tab.vue b/src/client/components/tab.vue index a10c7fdbf3..642dbb0e61 100644 --- a/src/client/components/tab.vue +++ b/src/client/components/tab.vue @@ -1,13 +1,13 @@ <template> <div class="pxhvhrfw" v-size="{ max: [500] }"> - <button v-for="item in items" class="_button" @click="$emit('input', item.value)" :class="{ active: value === item.value }" :key="item.value"><fa v-if="item.icon" :icon="item.icon" class="icon"/>{{ item.label }}</button> + <button v-for="item in items" class="_button" @click="$emit('update:value', item.value)" :class="{ active: value === item.value }" :key="item.value"><Fa v-if="item.icon" :icon="item.icon" class="icon"/>{{ item.label }}</button> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; -export default Vue.extend({ +export default defineComponent({ props: { items: { type: Array, @@ -23,10 +23,12 @@ export default Vue.extend({ <style lang="scss" scoped> .pxhvhrfw { display: flex; + max-width: var(--baseContentWidth); + margin: 0 auto; > button { flex: 1; - padding: 11px 8px 8px 8px; + padding: 15px 12px 12px 12px; border-bottom: solid 3px transparent; &.active { @@ -41,6 +43,10 @@ export default Vue.extend({ &.max-width_500px { font-size: 80%; + + > button { + padding: 11px 8px 8px 8px; + } } } </style> diff --git a/src/client/components/time.vue b/src/client/components/time.vue index e84f0aa25c..3219373739 100644 --- a/src/client/components/time.vue +++ b/src/client/components/time.vue @@ -1,15 +1,15 @@ <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> + <template v-if="mode == 'relative'">{{ relative }}</template> + <template v-else-if="mode == 'absolute'">{{ absolute }}</template> + <template v-else-if="mode == 'detail'">{{ absolute }} ({{ relative }})</template> </time> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; -export default Vue.extend({ +export default defineComponent({ props: { time: { type: [Date, String], @@ -54,7 +54,7 @@ export default Vue.extend({ this.tickId = window.requestAnimationFrame(this.tick); } }, - destroyed() { + unmounted() { if (this.mode === 'relative' || this.mode === 'detail') { window.clearTimeout(this.tickId); } diff --git a/src/client/components/timeline.vue b/src/client/components/timeline.vue index cd78d53cfc..930f47b1a5 100644 --- a/src/client/components/timeline.vue +++ b/src/client/components/timeline.vue @@ -1,16 +1,23 @@ <template> -<x-notes ref="tl" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)" @queue="$emit('queue', $event)"/> +<XNotes ref="tl" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)" @queue="$emit('queue', $event)"/> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import XNotes from './notes.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XNotes }, + provide() { + return { + inChannel: this.src === 'channel' + }; + }, + props: { src: { type: String, @@ -35,11 +42,7 @@ export default Vue.extend({ } }, - provide() { - return { - inChannel: this.src === 'channel' - }; - }, + emits: ['note', 'queue', 'before', 'after'], data() { return { @@ -56,18 +59,13 @@ export default Vue.extend({ }, created() { - this.$once('hook:beforeDestroy', () => { - this.connection.dispose(); - if (this.connection2) this.connection2.dispose(); - }); - const prepend = note => { (this.$refs.tl as any).prepend(note); this.$emit('note'); if (this.sound) { - this.$root.sound(note.userId === this.$store.state.i.id ? 'noteMy' : 'note'); + os.sound(note.userId === this.$store.state.i.id ? 'noteMy' : 'note'); } }; @@ -92,36 +90,36 @@ export default Vue.extend({ this.query = { antennaId: this.antenna }; - this.connection = this.$root.stream.connectToChannel('antenna', { + this.connection = os.stream.connectToChannel('antenna', { antennaId: this.antenna }); this.connection.on('note', prepend); } else if (this.src == 'home') { endpoint = 'notes/timeline'; - this.connection = this.$root.stream.useSharedConnection('homeTimeline'); + this.connection = os.stream.useSharedConnection('homeTimeline'); this.connection.on('note', prepend); - this.connection2 = this.$root.stream.useSharedConnection('main'); + this.connection2 = os.stream.useSharedConnection('main'); this.connection2.on('follow', onChangeFollowing); this.connection2.on('unfollow', onChangeFollowing); } else if (this.src == 'local') { endpoint = 'notes/local-timeline'; - this.connection = this.$root.stream.useSharedConnection('localTimeline'); + this.connection = os.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 = os.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 = os.stream.useSharedConnection('globalTimeline'); this.connection.on('note', prepend); } else if (this.src == 'list') { endpoint = 'notes/user-list-timeline'; this.query = { listId: this.list }; - this.connection = this.$root.stream.connectToChannel('userList', { + this.connection = os.stream.connectToChannel('userList', { listId: this.list }); this.connection.on('note', prepend); @@ -132,7 +130,7 @@ export default Vue.extend({ this.query = { channelId: this.channel }; - this.connection = this.$root.stream.connectToChannel('channel', { + this.connection = os.stream.connectToChannel('channel', { channelId: this.channel }); this.connection.on('note', prepend); @@ -148,6 +146,11 @@ export default Vue.extend({ }; }, + beforeUnmount() { + this.connection.dispose(); + if (this.connection2) this.connection2.dispose(); + }, + methods: { focus() { this.$refs.tl.focus(); diff --git a/src/client/components/toast.vue b/src/client/components/toast.vue index fefe91e3bd..fb0de68092 100644 --- a/src/client/components/toast.vue +++ b/src/client/components/toast.vue @@ -1,16 +1,16 @@ <template> <div class="mk-toast"> - <transition name="notification-slide" appear @after-leave="() => { destroyDom(); }"> - <x-notification :notification="notification" class="notification" v-if="show"/> + <transition name="notification-slide" appear @after-leave="$emit('closed')"> + <XNotification :notification="notification" class="notification _acrylic" v-if="showing"/> </transition> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import XNotification from './notification.vue'; -export default Vue.extend({ +export default defineComponent({ components: { XNotification }, @@ -20,14 +20,15 @@ export default Vue.extend({ required: true } }, + emits: ['closed'], data() { return { - show: true + showing: true }; }, mounted() { setTimeout(() => { - this.show = false; + this.showing = false; }, 6000); } }); @@ -37,7 +38,7 @@ export default Vue.extend({ .notification-slide-enter-active, .notification-slide-leave-active { transition: opacity 0.3s, transform 0.3s !important; } -.notification-slide-enter, .notification-slide-leave-to { +.notification-slide-enter-from, .notification-slide-leave-to { opacity: 0; transform: translateX(-250px); } @@ -64,12 +65,8 @@ export default Vue.extend({ > .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; } } diff --git a/src/client/components/token-generate-window.vue b/src/client/components/token-generate-window.vue index 51358d71bc..b46bb67b0f 100644 --- a/src/client/components/token-generate-window.vue +++ b/src/client/components/token-generate-window.vue @@ -1,36 +1,43 @@ <template> -<x-window ref="window" :width="400" :height="450" :no-padding="true" @closed="() => { $emit('closed'); destroyDom(); }" :with-ok-button="true" :ok-button-disabled="false" @ok="ok()" :can-close="false"> +<XModalWindow ref="dialog" + :width="400" + :height="450" + :with-ok-button="true" + :ok-button-disabled="false" + :can-close="false" + @close="$refs.dialog.close()" + @closed="$emit('closed')" + @ok="ok()" +> <template #header>{{ title || $t('generateAccessToken') }}</template> - <div class="ugkkpisj"> - <div v-if="information"> - <mk-info warn>{{ information }}</mk-info> - </div> - <div> - <mk-input v-model="name">{{ $t('name') }}</mk-input> - </div> - <div> - <div style="margin-bottom: 16px;"><b>{{ $t('permission') }}</b></div> - <mk-button inline @click="disableAll">{{ $t('disableAll') }}</mk-button> - <mk-button inline @click="enableAll">{{ $t('enableAll') }}</mk-button> - <mk-switch v-for="kind in (initialPermissions || kinds)" :key="kind" v-model="permissions[kind]">{{ $t(`_permissions.${kind}`) }}</mk-switch> - </div> + <div v-if="information" class="_section"> + <MkInfo warn>{{ information }}</MkInfo> </div> -</x-window> + <div class="_section"> + <MkInput v-model:value="name">{{ $t('name') }}</MkInput> + </div> + <div class="_section"> + <div style="margin-bottom: 16px;"><b>{{ $t('permission') }}</b></div> + <MkButton inline @click="disableAll">{{ $t('disableAll') }}</MkButton> + <MkButton inline @click="enableAll">{{ $t('enableAll') }}</MkButton> + <MkSwitch v-for="kind in (initialPermissions || kinds)" :key="kind" v-model:value="permissions[kind]">{{ $t(`_permissions.${kind}`) }}</MkSwitch> + </div> +</XModalWindow> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { kinds } from '../../misc/api-permissions'; -import XWindow from './window.vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; import MkInput from './ui/input.vue'; import MkTextarea from './ui/textarea.vue'; import MkSwitch from './ui/switch.vue'; import MkButton from './ui/button.vue'; import MkInfo from './ui/info.vue'; -export default Vue.extend({ +export default defineComponent({ components: { - XWindow, + XModalWindow, MkInput, MkTextarea, MkSwitch, @@ -61,6 +68,8 @@ export default Vue.extend({ } }, + emits: ['done', 'closed'], + data() { return { name: this.initialName, @@ -72,22 +81,22 @@ export default Vue.extend({ created() { if (this.initialPermissions) { for (const kind of this.initialPermissions) { - Vue.set(this.permissions, kind, true); + this.permissions[kind] = true; } } else { for (const kind of this.kinds) { - Vue.set(this.permissions, kind, false); + this.permissions[kind] = false; } } }, methods: { ok() { - this.$emit('ok', { + this.$emit('done', { name: this.name, permissions: Object.keys(this.permissions).filter(p => this.permissions[p]) }); - this.$refs.window.close(); + this.$refs.dialog.close(); }, disableAll() { @@ -104,12 +113,3 @@ export default Vue.extend({ } }); </script> - -<style lang="scss" scoped> -.ugkkpisj { - > div { - padding: 24px; - border-top: solid 1px var(--divider); - } -} -</style> diff --git a/src/client/components/ui/button.vue b/src/client/components/ui/button.vue index e5abf37be3..58b0f7b6d0 100644 --- a/src/client/components/ui/button.vue +++ b/src/client/components/ui/button.vue @@ -1,7 +1,7 @@ <template> <component class="bghgjjyj _button" :is="link ? 'a' : 'button'" - :class="{ inline, primary }" + :class="{ inline, primary, danger, full }" :type="type" @click="$emit('click', $event)" @mousedown="onMousedown" @@ -14,8 +14,9 @@ </template> <script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ +import { defineComponent } from 'vue'; + +export default defineComponent({ props: { type: { type: String, @@ -46,7 +47,18 @@ export default Vue.extend({ required: false, default: false }, + danger: { + type: Boolean, + required: false, + default: false + }, + full: { + type: Boolean, + required: false, + default: false + }, }, + emits: ['click'], mounted() { if (this.autofocus) { this.$nextTick(() => { @@ -100,6 +112,7 @@ export default Vue.extend({ <style lang="scss" scoped> .bghgjjyj { position: relative; + z-index: 1; // 他コンポーネントのbox-shadowに隠されないようにするため display: block; min-width: 100px; padding: 8px 14px; @@ -121,6 +134,10 @@ export default Vue.extend({ background: var(--buttonHoverBg); } + &.full { + width: 100%; + } + &.primary { color: #fff; background: var(--accent); @@ -134,6 +151,23 @@ export default Vue.extend({ } } + &.danger { + color: #ff2a2a; + + &.primary { + color: #fff; + background: #ff2a2a; + + &:not(:disabled):hover { + background: #ff4242; + } + + &:not(:disabled):active { + background: #d42e2e; + } + } + } + &:disabled { opacity: 0.7; } @@ -180,7 +214,7 @@ export default Vue.extend({ border-radius: 6px; overflow: hidden; - ::v-deep div { + ::v-deep(div) { position: absolute; width: 2px; height: 2px; @@ -192,7 +226,7 @@ export default Vue.extend({ } } - &.primary > .ripples ::v-deep div { + &.primary > .ripples ::v-deep(div) { background: rgba(0, 0, 0, 0.15); } diff --git a/src/client/components/ui/container.vue b/src/client/components/ui/container.vue index 382dd76eff..a47b174e8c 100644 --- a/src/client/components/ui/container.vue +++ b/src/client/components/ui/container.vue @@ -1,12 +1,12 @@ <template> -<div class="ukygtjoj _panel" :class="{ naked, hideHeader: !showHeader, scrollable, closed: !showBody }" v-size="{ max: [380], el: resizeBaseEl }"> +<div class="ukygtjoj _panel" :class="{ naked, hideHeader: !showHeader, scrollable, closed: !showBody }" v-size="{ max: [380] }"> <header v-if="showHeader" ref="header"> <div class="title"><slot name="header"></slot></div> <div class="sub"> <slot name="func"></slot> <button class="_button" v-if="bodyTogglable" @click="() => showBody = !showBody"> - <template v-if="showBody"><fa :icon="faAngleUp"/></template> - <template v-else><fa :icon="faAngleDown"/></template> + <template v-if="showBody"><Fa :icon="faAngleUp"/></template> + <template v-else><Fa :icon="faAngleDown"/></template> </button> </div> </header> @@ -24,10 +24,10 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons'; -export default Vue.extend({ +export default defineComponent({ props: { showHeader: { type: Boolean, @@ -54,9 +54,6 @@ export default Vue.extend({ required: false, default: false }, - resizeBaseEl: { - required: false, - }, }, data() { return { @@ -66,11 +63,12 @@ export default Vue.extend({ }, mounted() { this.$watch('showBody', showBody => { - this.$el.style.minHeight = `${this.$refs.header.offsetHeight}px`; + const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0; + this.$el.style.minHeight = `${headerHeight}px`; if (showBody) { this.$el.style.flexBasis = `auto`; } else { - this.$el.style.flexBasis = `${this.$refs.header.offsetHeight}px`; + this.$el.style.flexBasis = `${headerHeight}px`; } }, { immediate: true @@ -109,7 +107,7 @@ export default Vue.extend({ overflow-y: hidden; transition: opacity 0.5s, height 0.5s !important; } -.container-toggle-enter { +.container-toggle-enter-from { opacity: 0; } .container-toggle-leave-to { @@ -138,15 +136,13 @@ export default Vue.extend({ position: relative; box-shadow: 0 1px 0 0 var(--panelHeaderDivider); z-index: 2; - background: var(--panelHeaderBg); - color: var(--panelHeaderFg); line-height: 1.4em; > .title { margin: 0; padding: 12px 16px; - > [data-icon] { + > ::v-deep([data-icon]) { margin-right: 6px; } @@ -162,7 +158,7 @@ export default Vue.extend({ right: 0; height: 100%; - > button { + > ::v-deep(button) { width: 42px; height: 100%; } @@ -170,7 +166,7 @@ export default Vue.extend({ } > div { - > ::v-deep ._content { + > ::v-deep(._content) { padding: 24px; & + ._content { @@ -187,7 +183,7 @@ export default Vue.extend({ } > div { - > ::v-deep ._content { + > ::v-deep(._content) { padding: 16px; } } diff --git a/src/client/components/ui/context-menu.vue b/src/client/components/ui/context-menu.vue new file mode 100644 index 0000000000..98586cf3fe --- /dev/null +++ b/src/client/components/ui/context-menu.vue @@ -0,0 +1,63 @@ +<template> +<div class="nvlagfpb"> + <MkMenu :items="items" @close="$emit('closed')" class="_popup _shadow" :align="'left'"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import contains from '@/scripts/contains'; +import MkMenu from './menu.vue'; + +export default defineComponent({ + components: { + MkMenu, + }, + props: { + items: { + type: Array, + required: true + }, + ev: { + required: true + }, + viaKeyboard: { + type: Boolean, + required: false + }, + }, + emits: ['closed'], + computed: { + keymap(): any { + return { + 'esc': () => this.$emit('closed'), + }; + }, + }, + mounted() { + this.$el.style.top = this.ev.pageY + 'px'; + this.$el.style.left = this.ev.pageX + 'px'; + + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.addEventListener('mousedown', this.onMousedown); + } + }, + beforeUnmount() { + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.removeEventListener('mousedown', this.onMousedown); + } + }, + methods: { + onMousedown(e) { + if (!contains(this.$el, e.target) && (this.$el != e.target)) this.$emit('closed'); + }, + } +}); +</script> + +<style lang="scss" scoped> +.nvlagfpb { + position: absolute; + z-index: 65535; +} +</style> diff --git a/src/client/components/ui/folder.vue b/src/client/components/ui/folder.vue index 0b489fe9ad..1eaf881ffe 100644 --- a/src/client/components/ui/folder.vue +++ b/src/client/components/ui/folder.vue @@ -1,11 +1,11 @@ <template> <div class="ssazuxis" v-size="{ max: [500] }"> - <header @click="() => showBody = !showBody" class="_button"> + <header @click="showBody = !showBody" class="_button"> <div class="title"><slot name="header"></slot></div> <div class="divider"></div> <button class="_button"> - <template v-if="showBody"><fa :icon="faAngleUp"/></template> - <template v-else><fa :icon="faAngleDown"/></template> + <template v-if="showBody"><Fa :icon="faAngleUp"/></template> + <template v-else><Fa :icon="faAngleDown"/></template> </button> </header> <transition name="folder-toggle" @@ -22,23 +22,37 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons'; -export default Vue.extend({ +const localStoragePrefix = 'ui:folder:'; + +export default defineComponent({ props: { expanded: { type: Boolean, required: false, default: true }, + persistKey: { + type: String, + required: false, + default: null + }, }, data() { return { - showBody: this.expanded, + showBody: (this.persistKey && localStorage.getItem(localStoragePrefix + this.persistKey)) ? localStorage.getItem(localStoragePrefix + this.persistKey) === 't' : this.expanded, faAngleUp, faAngleDown }; }, + watch: { + showBody() { + if (this.persistKey) { + localStorage.setItem(localStoragePrefix + this.persistKey, this.showBody ? 't' : 'f'); + } + } + }, methods: { toggleContent(show: boolean) { this.showBody = show; @@ -71,7 +85,7 @@ export default Vue.extend({ overflow-y: hidden; transition: opacity 0.5s, height 0.5s !important; } -.folder-toggle-enter { +.folder-toggle-enter-from { opacity: 0; } .folder-toggle-leave-to { @@ -92,7 +106,7 @@ export default Vue.extend({ > .title { margin: 0; - padding: 12px 16px 12px 8px; + padding: 12px 16px 12px 0; > [data-icon] { margin-right: 6px; @@ -111,7 +125,7 @@ export default Vue.extend({ } > button { - width: 42px; + padding: 12px 0 12px 16px; } } diff --git a/src/client/components/ui/hr.vue b/src/client/components/ui/hr.vue index ae7f7dbf8e..6b075cb440 100644 --- a/src/client/components/ui/hr.vue +++ b/src/client/components/ui/hr.vue @@ -3,8 +3,9 @@ </template> <script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({}); +import { defineComponent } from 'vue';import * as os from '@/os'; + +export default defineComponent({}); </script> <style lang="scss" scoped> diff --git a/src/client/components/ui/info.vue b/src/client/components/ui/info.vue index 3e87fe261d..3bdb69b3d1 100644 --- a/src/client/components/ui/info.vue +++ b/src/client/components/ui/info.vue @@ -1,16 +1,17 @@ <template> <div class="fpezltsf" :class="{ warn }"> - <i v-if="warn"><fa :icon="faExclamationTriangle"/></i> - <i v-else><fa :icon="faInfoCircle"/></i> + <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 { defineComponent } from 'vue'; import { faInfoCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { warn: { type: Boolean, diff --git a/src/client/components/ui/input.vue b/src/client/components/ui/input.vue index f9c2d9a43a..dec4a08712 100644 --- a/src/client/components/ui/input.vue +++ b/src/client/components/ui/input.vue @@ -2,66 +2,51 @@ <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="label" ref="labelEl"><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 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" - :step="step" - @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" - :step="step" - @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 class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> + <input v-if="debounce" ref="inputEl" + v-debounce="500" + :type="type" + v-model.lazy="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + :step="step" + @focus="focused = true" + @blur="focused = false" + @keydown="onKeydown($event)" + @input="onInput" + :list="id" + > + <input v-else ref="inputEl" + :type="type" + v-model="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + :step="step" + @focus="focused = true" + @blur="focused = false" + @keydown="onKeydown($event)" + @input="onInput" + :list="id" + > + <datalist :id="id" v-if="datalist"> + <option v-for="data in datalist" :value="data"/> + </datalist> + <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div> </div> <button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $t('save') }}</button> <div class="desc _caption"><slot name="desc"></slot></div> @@ -69,11 +54,12 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; import debounce from 'v-debounce'; import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ directives: { debounce }, @@ -136,106 +122,92 @@ export default Vue.extend({ 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; + emits: ['change', 'keydown', 'enter'], + setup(props, context) { + const { value, type, autofocus } = toRefs(props); + const v = ref(value.value); + const id = Math.random().toString(); // TODO: uuid? + const focused = ref(false); + const changed = ref(false); + const invalid = ref(false); + const filled = computed(() => v.value !== '' && v.value != null); + const inputEl = ref(null); + const prefixEl = ref(null); + const suffixEl = ref(null); + const labelEl = ref(null); - if (typeof this.v == 'string') return this.v; + const focus = () => inputEl.value.focus(); + const onInput = (ev) => { + changed.value = true; + context.emit('change', ev); + }; + const onKeydown = (ev: KeyboardEvent) => { + context.emit('keydown', ev); - if (Array.isArray(this.v)) { - return this.v.map(file => file.name).join(', '); - } else { - return this.v.name; + if (ev.code === 'Enter') { + context.emit('enter'); } - } - }, - watch: { - value(v) { - this.v = v; - }, - v(v) { - if (this.type === 'number') { - this.$emit('input', parseFloat(v)); + }; + + watch(value, newValue => { + v.value = newValue; + }); + + watch(v, newValue => { + if (type?.value === 'number') { + context.emit('update:value', parseFloat(newValue)); } else { - this.$emit('input', v); + context.emit('update:value', newValue); } - this.invalid = this.$refs.input.validity.badInput; - } - }, - mounted() { - if (this.autofocus) { - this.$nextTick(() => { - this.$refs.input.focus(); - }); - } + invalid.value = inputEl.value.validity.badInput; + }); - 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'; - } + onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); } - if (this.$refs.suffix) { - if (this.$refs.suffix.offsetWidth) { - this.$refs.input.style.paddingRight = this.$refs.suffix.offsetWidth + 'px'; + + // このコンポーネントが作成された時、非表示状態である場合がある + // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する + const clock = setInterval(() => { + if (prefixEl.value) { + labelEl.value.style.left = (prefixEl.value.offsetLeft + prefixEl.value.offsetWidth) + 'px'; + if (prefixEl.value.offsetWidth) { + inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; + } } - } - }, 100); + if (suffixEl.value) { + if (suffixEl.value.offsetWidth) { + inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; + } + } + }, 100); - this.$once('hook:beforeDestroy', () => { - clearInterval(clock); + onUnmounted(() => { + clearInterval(clock); + }); }); }); - this.$on('keydown', (e: KeyboardEvent) => { - if (e.code == 'Enter') { - this.$emit('enter'); - } - }); + return { + id, + v, + focused, + invalid, + changed, + filled, + inputEl, + prefixEl, + suffixEl, + labelEl, + focus, + onInput, + onKeydown, + faExclamationCircle, + }; }, - 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> diff --git a/src/client/components/ui/menu.vue b/src/client/components/ui/menu.vue new file mode 100644 index 0000000000..5e74828c20 --- /dev/null +++ b/src/client/components/ui/menu.vue @@ -0,0 +1,237 @@ +<template> +<div class="rrevdjwt" :class="{ left: align === 'left' }" + ref="items" + @contextmenu.self="e => e.preventDefault()" + v-hotkey="keymap" +> + <template v-for="(item, i) in _items"> + <div v-if="item === null" class="divider"></div> + <span v-else-if="item.type === 'label'" class="label item"> + <span>{{ item.text }}</span> + </span> + <span v-else-if="item.type === 'pending'" :tabindex="i" class="pending item"> + <span><MkEllipsis/></span> + </span> + <router-link v-else-if="item.type === 'link'" :to="item.to" @click.passive="close()" :tabindex="i" class="_button item"> + <Fa v-if="item.icon" :icon="item.icon" fixed-width/> + <MkAvatar 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"> + <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"> + <MkAvatar :user="item.user" class="avatar"/><MkUserName :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" :class="{ danger: item.danger }"> + <Fa v-if="item.icon" :icon="item.icon" fixed-width/> + <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/> + <span>{{ item.text }}</span> + <i v-if="item.indicate"><Fa :icon="faCircle"/></i> + </button> + </template> + <span v-if="_items.length === 0" class="none item"> + <span>{{ $t('none') }}</span> + </span> +</div> +</template> + +<script lang="ts"> +import { defineComponent, ref } from 'vue'; +import { faCircle } from '@fortawesome/free-solid-svg-icons'; +import { focusPrev, focusNext } from '@/scripts/focus'; +import contains from '@/scripts/contains'; + +export default defineComponent({ + props: { + items: { + type: Array, + required: true + }, + viaKeyboard: { + type: Boolean, + required: false + }, + align: { + type: String, + requried: false + } + }, + emits: ['close'], + data() { + return { + _items: [], + faCircle, + }; + }, + computed: { + keymap(): any { + return { + 'up|k|shift+tab': this.focusUp, + 'down|j|tab': this.focusDown, + 'esc': this.close, + }; + }, + }, + created() { + const items = ref(this.items.filter(item => item !== undefined)); + + for (let i = 0; i < items.value.length; i++) { + const item = items.value[i]; + + if (item && item.then) { // if item is Promise + items.value[i] = { type: 'pending' }; + item.then(actualItem => { + items.value[i] = actualItem; + }); + } + } + + this._items = items; + }, + mounted() { + if (this.viaKeyboard) { + this.$nextTick(() => { + focusNext(this.$refs.items.children[0], true, false); + }); + } + + if (this.contextmenuEvent) { + this.$el.style.top = this.contextmenuEvent.pageY + 'px'; + this.$el.style.left = this.contextmenuEvent.pageX + 'px'; + + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.addEventListener('mousedown', this.onMousedown); + } + } + }, + beforeUnmount() { + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.removeEventListener('mousedown', this.onMousedown); + } + }, + methods: { + clicked(fn) { + fn(); + this.close(); + }, + close() { + this.$emit('close'); + }, + focusUp() { + focusPrev(document.activeElement); + }, + focusDown() { + focusNext(document.activeElement); + }, + onMousedown(e) { + if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.rrevdjwt { + padding: 8px 0; + + &.left { + > .item { + text-align: left; + } + } + + > .item { + display: block; + position: relative; + padding: 8px 16px; + width: 100%; + box-sizing: border-box; + white-space: nowrap; + font-size: 0.9em; + line-height: 20px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + + &.danger { + color: #ff2a2a; + + &:hover { + color: #fff; + background: #ff4242; + } + + &:active { + color: #fff; + background: #d42e2e; + } + } + + &:hover { + color: #fff; + background: var(--accent); + text-decoration: none; + } + + &:active { + color: #fff; + background: var(--accentDarken); + } + + &:not(:active):focus { + box-shadow: 0 0 0 2px var(--focus) inset; + } + + &.label { + pointer-events: none; + font-size: 0.7em; + padding-bottom: 4px; + + > span { + opacity: 0.7; + } + } + + &.pending { + pointer-events: none; + opacity: 0.7; + } + + &.none { + pointer-events: none; + opacity: 0.7; + } + + > [data-icon] { + margin-right: 4px; + width: 20px; + } + + > .avatar { + margin-right: 4px; + width: 20px; + height: 20px; + } + + > i { + position: absolute; + top: 5px; + left: 13px; + color: var(--indicator); + font-size: 12px; + animation: blink 1s infinite; + } + } + + > .divider { + margin: 8px 0; + height: 1px; + background: var(--divider); + } +} +</style> diff --git a/src/client/components/ui/modal-menu.vue b/src/client/components/ui/modal-menu.vue new file mode 100644 index 0000000000..aac4be9c3b --- /dev/null +++ b/src/client/components/ui/modal-menu.vue @@ -0,0 +1,47 @@ +<template> +<MkModal ref="modal" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')"> + <MkMenu :items="items" :align="align" @close="$refs.modal.close()" class="_popup"/> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from './modal.vue'; +import MkMenu from './menu.vue'; + +export default defineComponent({ + components: { + MkModal, + MkMenu, + }, + props: { + items: { + type: Array, + required: true + }, + align: { + type: String, + required: false + }, + viaKeyboard: { + type: Boolean, + required: false + }, + src: { + required: false + }, + }, + emits: ['closed'], + computed: { + keymap(): any { + return { + 'esc': () => this.$refs.modal.close(), + }; + }, + }, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/src/client/components/window.vue b/src/client/components/ui/modal-window.vue index a0bff869b9..2cdf961379 100644 --- a/src/client/components/window.vue +++ b/src/client/components/ui/modal-window.vue @@ -1,37 +1,36 @@ <template> -<x-modal ref="modal" @closed="() => { $emit('closed'); destroyDom(); }" :can-close="canClose"> - <div class="ebkgoccj" :class="{ noPadding }" @keydown="onKeydown" :style="{ width: `${width}px`, height: `${height}px` }"> +<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')"> + <div class="ebkgoccj _popup _narrow_" @keydown="onKeydown" :style="{ width: `${width}px`, height: height ? `${height}px` : null }"> <div class="header"> - <button class="_button" v-if="withOkButton" @click="close()"><fa :icon="faTimes"/></button> + <button class="_button" v-if="withOkButton" @click="$emit('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> + <button class="_button" v-if="!withOkButton" @click="$emit('close')"><Fa :icon="faTimes"/></button> + <button class="_button" v-if="withOkButton" @click="$emit('ok')" :disabled="okButtonDisabled"><Fa :icon="faCheck"/></button> </div> - <div class="body"> + <div class="body" v-if="padding"> + <div class="_section"> + <slot></slot> + </div> + </div> + <div class="body" v-else> <slot></slot> </div> </div> -</x-modal> +</MkModal> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faTimes, faCheck } from '@fortawesome/free-solid-svg-icons'; -import XModal from './modal.vue'; +import MkModal from './modal.vue'; -export default Vue.extend({ +export default defineComponent({ components: { - XModal, + MkModal }, - props: { - avatar: { - type: Object, - required: false - }, withOkButton: { type: Boolean, required: false, @@ -42,7 +41,7 @@ export default Vue.extend({ required: false, default: false }, - noPadding: { + padding: { type: Boolean, required: false, default: false @@ -55,7 +54,7 @@ export default Vue.extend({ height: { type: Number, required: false, - default: 400 + default: null }, canClose: { type: Boolean, @@ -64,6 +63,8 @@ export default Vue.extend({ }, }, + emits: ['click', 'close', 'closed', 'ok'], + data() { return { faTimes, faCheck @@ -88,17 +89,23 @@ export default Vue.extend({ <style lang="scss" scoped> .ebkgoccj { - background: var(--panel); - border-radius: var(--radius); overflow: hidden; display: flex; flex-direction: column; + contain: content; + + --section-padding: 24px; + + @media (max-width: 500px) { + --section-padding: 16px; + } > .header { $height: 58px; $height-narrow: 42px; display: flex; flex-shrink: 0; + box-shadow: 0px 1px var(--divider); > button { height: $height; @@ -124,20 +131,6 @@ export default Vue.extend({ 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 { @@ -148,13 +141,5 @@ export default Vue.extend({ > .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/components/ui/modal.vue b/src/client/components/ui/modal.vue new file mode 100644 index 0000000000..4cc96bb8da --- /dev/null +++ b/src/client/components/ui/modal.vue @@ -0,0 +1,232 @@ +<template> +<div class="mk-modal" v-hotkey.global="keymap" :style="{ pointerEvents: showing ? 'auto' : 'none' }"> + <transition :name="$store.state.device.animation ? 'modal-bg' : ''" appear> + <div class="bg _modalBg" v-if="showing" @click="onBgClick"></div> + </transition> + <div class="content" :class="{ popup, fixed, top: position === 'top' }" @click.self="onBgClick" ref="content"> + <transition :name="$store.state.device.animation ? popup ? 'modal-popup-content' : 'modal-content' : ''" appear @after-leave="$emit('closed')" @after-enter="childRendered"> + <slot v-if="showing"></slot> + </transition> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +function getFixedContainer(el: Element | null): Element | null { + if (el == null || el.tagName === 'BODY') return null; + const position = window.getComputedStyle(el).getPropertyValue('position'); + if (position === 'fixed') { + return el; + } else { + return getFixedContainer(el.parentElement); + } +} + +export default defineComponent({ + provide: { + modal: true + }, + props: { + srcCenter: { + type: Boolean, + required: false + }, + src: { + required: false, + }, + position: { + required: false + } + }, + emits: ['click', 'esc', 'closed'], + data() { + return { + showing: true, + fixed: false, + transformOrigin: 'center', + contentClicking: false, + }; + }, + computed: { + keymap(): any { + return { + 'esc': () => this.$emit('esc'), + }; + }, + popup(): boolean { + return this.src != null; + } + }, + mounted() { + this.fixed = getFixedContainer(this.src) != null; + + this.$nextTick(() => { + if (!this.popup) return; + + const popover = this.$refs.content as any; + + // TODO: ResizeObserver無くしたい + new ResizeObserver((entries, observer) => { + const rect = this.src.getBoundingClientRect(); + const width = popover.offsetWidth; + const height = popover.offsetHeight; + + let left; + let top; + + if (this.srcCenter) { + const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2); + const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.src.offsetHeight / 2); + left = (x - (width / 2)); + top = (y - (height / 2)); + } else { + const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2); + const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.src.offsetHeight; + left = (x - (width / 2)); + top = y; + } + + if (this.fixed) { + if (left + width > window.innerWidth) { + left = window.innerWidth - width; + } + + if (top + height > window.innerHeight) { + top = window.innerHeight - height; + } + } else { + if (left + width - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - width + window.pageXOffset; + } + + if (top + height - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - height + window.pageYOffset; + } + } + + if (top < 0) { + top = 0; + } + + if (left < 0) { + left = 0; + } + + if (top > rect.top + (this.fixed ? 0 : window.pageYOffset)) { + this.transformOrigin = 'center top'; + } + + popover.style.left = left + 'px'; + popover.style.top = top + 'px'; + }).observe(popover); + }); + }, + methods: { + childRendered() { + // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する + const content = this.$refs.content.children[0]; + content.addEventListener('mousedown', e => { + this.contentClicking = true; + window.addEventListener('mouseup', e => { + // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ + setTimeout(() => { + this.contentClicking = false; + }, 100); + }, { passive: true, once: true }); + }, { passive: true }); + }, + + close() { + this.showing = false; + }, + + onBgClick() { + if (this.contentClicking) return; + this.$emit('click'); + } + } +}); +</script> + +<style vars="{ transformOrigin }"> +.modal-popup-content-enter-active, .modal-popup-content-leave-active, +.modal-content-enter-from, .modal-content-leave-to { + transform-origin: var(--transformOrigin); +} +</style> + +<style lang="scss" scoped> +.modal-bg-enter-active, .modal-bg-leave-active { + transition: opacity 0.3s !important; +} +.modal-bg-enter-from, .modal-bg-leave-to { + opacity: 0; +} + +.modal-content-enter-active, .modal-content-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.modal-content-enter-from, .modal-content-leave-to { + pointer-events: none; + opacity: 0; + transform: scale(0.9); +} + +.modal-popup-content-enter-active, .modal-popup-content-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.modal-popup-content-enter-from, .modal-popup-content-leave-to { + pointer-events: none; + opacity: 0; + transform: scale(0.9); +} + +.mk-modal { + > .bg { + z-index: 10000; + } + + > .content:not(.popup) { + position: fixed; + z-index: 10000; + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: auto; + padding: 32px; + // TODO: mask-imageはiOSだとやたら重い。なんとかしたい + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%); + overflow: auto; + display: flex; + + @media (max-width: 500px) { + padding: 16px; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%); + } + + > * { + margin: auto; + } + + &.top { + > * { + margin-top: 0; + } + } + } + + > .content.popup { + position: absolute; + z-index: 10000; + + &.fixed { + position: fixed; + } + } +} +</style> diff --git a/src/client/components/ui/pagination.vue b/src/client/components/ui/pagination.vue index 0db6ee20dc..fa584f3aab 100644 --- a/src/client/components/ui/pagination.vue +++ b/src/client/components/ui/pagination.vue @@ -5,20 +5,20 @@ <slot name="empty"></slot> </div> <div class="more" v-show="more" key="_more_"> - <mk-button class="button" ref="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary> + <MkButton class="button" v-appear="$store.state.device.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary> <template v-if="!moreFetching">{{ $t('loadMore') }}</template> - <template v-if="moreFetching"><mk-loading inline/></template> - </mk-button> + <template v-if="moreFetching"><MkLoading inline/></template> + </MkButton> </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import MkButton from './button.vue'; -import paging from '../../scripts/paging'; +import paging from '@/scripts/paging'; -export default Vue.extend({ +export default defineComponent({ components: { MkButton }, diff --git a/src/client/components/ui/radio.vue b/src/client/components/ui/radio.vue index 311cdce32d..8f2b843ee6 100644 --- a/src/client/components/ui/radio.vue +++ b/src/client/components/ui/radio.vue @@ -17,14 +17,12 @@ </template> <script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - model: { - prop: 'model', - event: 'change' - }, +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ props: { - model: { + modelValue: { required: false }, value: { @@ -37,13 +35,13 @@ export default Vue.extend({ }, computed: { checked(): boolean { - return this.model === this.value; + return this.modelValue === this.value; } }, methods: { toggle() { if (this.disabled) return; - this.$emit('change', this.value); + this.$emit('update:modelValue', this.value); } } }); @@ -51,6 +49,7 @@ export default Vue.extend({ <style lang="scss" scoped> .novjtctn { + position: relative; display: inline-block; margin: 0 32px 0 0; cursor: pointer; diff --git a/src/client/components/ui/range.vue b/src/client/components/ui/range.vue index 2c815912bb..c6e585cf50 100644 --- a/src/client/components/ui/range.vue +++ b/src/client/components/ui/range.vue @@ -13,14 +13,15 @@ :autofocus="autofocus" @focus="focused = true" @blur="focused = false" - @input="$emit('input', $event.target.value)" + @input="$emit('update:value', $event.target.value)" /> </div> </template> <script lang="ts"> -import Vue from "vue"; -export default Vue.extend({ +import { defineComponent } from 'vue';import * as os from '@/os'; + +export default defineComponent({ props: { value: { type: Number, diff --git a/src/client/components/ui/select.vue b/src/client/components/ui/select.vue index cb737df6ed..aaaddacb29 100644 --- a/src/client/components/ui/select.vue +++ b/src/client/components/ui/select.vue @@ -15,7 +15,7 @@ </select> <div class="suffix"> <slot name="suffix"> - <fa :icon="faChevronDown"/> + <Fa :icon="faChevronDown"/> </slot> </div> </div> @@ -24,10 +24,11 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faChevronDown } from '@fortawesome/free-solid-svg-icons'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { value: { required: false @@ -58,7 +59,7 @@ export default Vue.extend({ return this.value; }, set(v) { - this.$emit('input', v); + this.$emit('update:value', v); } }, filled(): boolean { @@ -169,6 +170,7 @@ export default Vue.extend({ option, optgroup { + color: var(--fg); background: var(--bg); } } diff --git a/src/client/components/ui/switch.vue b/src/client/components/ui/switch.vue index 9652a01024..f738257232 100644 --- a/src/client/components/ui/switch.vue +++ b/src/client/components/ui/switch.vue @@ -26,12 +26,9 @@ </template> <script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - model: { - prop: 'value', - event: 'change' - }, +import { defineComponent } from 'vue'; + +export default defineComponent({ props: { value: { type: Boolean, @@ -50,7 +47,7 @@ export default Vue.extend({ methods: { toggle() { if (this.disabled) return; - this.$emit('change', !this.checked); + this.$emit('update:value', !this.checked); } } }); diff --git a/src/client/components/ui/textarea.vue b/src/client/components/ui/textarea.vue index fba9fc9d78..6820be8a7c 100644 --- a/src/client/components/ui/textarea.vue +++ b/src/client/components/ui/textarea.vue @@ -19,9 +19,10 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { value: { required: false @@ -74,7 +75,7 @@ export default Vue.extend({ }, onInput(ev) { this.changed = true; - this.$emit('input', ev.target.value); + this.$emit('update:value', ev.target.value); } } }); diff --git a/src/client/components/ui/tooltip.vue b/src/client/components/ui/tooltip.vue index b7a56708b7..6ea344c54d 100644 --- a/src/client/components/ui/tooltip.vue +++ b/src/client/components/ui/tooltip.vue @@ -1,16 +1,20 @@ <template> -<transition name="zoom-in-top" appear> - <div class="buebdbiu" v-if="show"> +<transition name="zoom-in-top" appear @after-leave="$emit('closed')"> + <div class="buebdbiu _acrylic _shadow" v-if="showing"> <slot>{{ text }}</slot> </div> </transition> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; -export default Vue.extend({ +export default defineComponent({ props: { + showing: { + type: Boolean, + required: true, + }, source: { required: true, }, @@ -20,77 +24,39 @@ export default Vue.extend({ } }, - data() { - return { - show: false - }; - }, + emits: ['closed'], mounted() { - this.show = true; - this.$nextTick(() => { if (this.source == null) { - this.destroyDom(); + this.$emit('closed'); 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; - this.$el.style.left = (x - 28) + 'px'; - this.$el.style.top = (y + 16) + 'px'; + let x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + let y = rect.top + window.pageYOffset + this.source.offsetHeight; + + x -= (this.$el.offsetWidth / 2); + + this.$el.style.left = x + 'px'; + this.$el.style.top = y + 'px'; }); }, - - methods: { - close() { - this.show = false; - setTimeout(this.destroyDom, 300); - } - } }) </script> <style lang="scss" scoped> .buebdbiu { - z-index: 11000; - display: block; position: absolute; + z-index: 11000; max-width: 240px; font-size: 0.8em; - padding: 6px 8px; - background: var(--panel); + padding: 8px 12px; 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/components/ui/window.vue b/src/client/components/ui/window.vue new file mode 100644 index 0000000000..cf76347d39 --- /dev/null +++ b/src/client/components/ui/window.vue @@ -0,0 +1,481 @@ +<template> +<transition :name="$store.state.device.animation ? 'window' : ''" appear @after-leave="$emit('closed')"> + <div class="ebkgocck" v-if="showing"> + <div class="body _popup _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown"> + <div class="header"> + <button class="_button" @click="close()"><Fa :icon="faTimes"/></button> + <span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown"> + <slot name="header"></slot> + </span> + <slot name="buttons"></slot> + </div> + <div class="body" v-if="padding"> + <div class="_section"> + <slot></slot> + </div> + </div> + <div class="body" v-else> + <slot></slot> + </div> + </div> + <template v-if="canResize"> + <div class="handle top" @mousedown.prevent="onTopHandleMousedown"></div> + <div class="handle right" @mousedown.prevent="onRightHandleMousedown"></div> + <div class="handle bottom" @mousedown.prevent="onBottomHandleMousedown"></div> + <div class="handle left" @mousedown.prevent="onLeftHandleMousedown"></div> + <div class="handle top-left" @mousedown.prevent="onTopLeftHandleMousedown"></div> + <div class="handle top-right" @mousedown.prevent="onTopRightHandleMousedown"></div> + <div class="handle bottom-right" @mousedown.prevent="onBottomRightHandleMousedown"></div> + <div class="handle bottom-left" @mousedown.prevent="onBottomLeftHandleMousedown"></div> + </template> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faTimes, faCheck } from '@fortawesome/free-solid-svg-icons'; +import contains from '@/scripts/contains'; +import * as os from '@/os'; + +const minHeight = 50; +const minWidth = 250; + +function dragListen(fn) { + window.addEventListener('mousemove', fn); + window.addEventListener('touchmove', fn); + window.addEventListener('mouseleave', dragClear.bind(null, fn)); + window.addEventListener('mouseup', dragClear.bind(null, fn)); + window.addEventListener('touchend', dragClear.bind(null, fn)); +} + +function dragClear(fn) { + window.removeEventListener('mousemove', fn); + window.removeEventListener('touchmove', fn); + window.removeEventListener('mouseleave', dragClear); + window.removeEventListener('mouseup', dragClear); + window.removeEventListener('touchend', dragClear); +} + +export default defineComponent({ + provide: { + inWindow: true + }, + + props: { + padding: { + type: Boolean, + required: false, + default: false + }, + initialWidth: { + type: Number, + required: false, + default: 400 + }, + initialHeight: { + type: Number, + required: false, + default: null + }, + canResize: { + type: Boolean, + required: false, + default: false, + }, + }, + + emits: ['closed'], + + data() { + return { + showing: true, + id: Math.random().toString(), // TODO: UUIDとかにする + faTimes + }; + }, + + mounted() { + if (this.initialWidth) this.applyTransformWidth(this.initialWidth); + if (this.initialHeight) this.applyTransformHeight(this.initialHeight); + + this.applyTransformTop((window.innerHeight / 2) - (this.$el.offsetHeight / 2)); + this.applyTransformLeft((window.innerWidth / 2) - (this.$el.offsetWidth / 2)); + + os.windows.set(this.id, { + z: Number(document.defaultView.getComputedStyle(this.$el, null).zIndex) + }); + + window.addEventListener('resize', this.onBrowserResize); + }, + + unmounted() { + os.windows.delete(this.id); + window.removeEventListener('resize', this.onBrowserResize); + }, + + methods: { + close() { + this.showing = false; + }, + + onKeydown(e) { + if (e.which === 27) { // Esc + e.preventDefault(); + e.stopPropagation(); + this.close(); + } + }, + + // 最前面へ移動 + top() { + let z = 0; + const ws = Array.from(os.windows.entries()).filter(([k, v]) => k !== this.id).map(([k, v]) => v); + for (const w of ws) { + if (w.z > z) z = w.z; + } + if (z > 0) { + (this.$el as any).style.zIndex = z + 1; + os.windows.set(this.id, { + z: z + 1 + }); + } + }, + + onBodyMousedown() { + this.top(); + }, + + onHeaderMousedown(e) { + const main = this.$el as any; + + if (!contains(main, document.activeElement)) main.focus(); + + const position = main.getBoundingClientRect(); + + const clickX = e.touches && e.touches.length > 0 ? e.touches[0].clientX : e.clientX; + const clickY = e.touches && e.touches.length > 0 ? e.touches[0].clientY : e.clientY; + const moveBaseX = clickX - position.left; + const moveBaseY = clickY - position.top; + const browserWidth = window.innerWidth; + const browserHeight = window.innerHeight; + const windowWidth = main.offsetWidth; + const windowHeight = main.offsetHeight; + + // 動かした時 + dragListen(me => { + const x = me.touches && me.touches.length > 0 ? me.touches[0].clientX : me.clientX; + const y = me.touches && me.touches.length > 0 ? me.touches[0].clientY : me.clientY; + + let moveLeft = x - moveBaseX; + let moveTop = y - moveBaseY; + + // 下はみ出し + if (moveTop + windowHeight > browserHeight) moveTop = browserHeight - windowHeight; + + // 左はみ出し + if (moveLeft < 0) moveLeft = 0; + + // 上はみ出し + if (moveTop < 0) moveTop = 0; + + // 右はみ出し + if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth; + + this.$el.style.left = moveLeft + 'px'; + this.$el.style.top = moveTop + 'px'; + }); + }, + + // 上ハンドル掴み時 + onTopHandleMousedown(e) { + const main = this.$el as any; + + const base = e.clientY; + const height = parseInt(getComputedStyle(main, '').height, 10); + const top = parseInt(getComputedStyle(main, '').top, 10); + + // 動かした時 + dragListen(me => { + const move = me.clientY - base; + if (top + move > 0) { + if (height + -move > minHeight) { + this.applyTransformHeight(height + -move); + this.applyTransformTop(top + move); + } else { // 最小の高さより小さくなろうとした時 + this.applyTransformHeight(minHeight); + this.applyTransformTop(top + (height - minHeight)); + } + } else { // 上のはみ出し時 + this.applyTransformHeight(top + height); + this.applyTransformTop(0); + } + }); + }, + + // 右ハンドル掴み時 + onRightHandleMousedown(e) { + const main = this.$el as any; + + const base = e.clientX; + const width = parseInt(getComputedStyle(main, '').width, 10); + const left = parseInt(getComputedStyle(main, '').left, 10); + const browserWidth = window.innerWidth; + + // 動かした時 + dragListen(me => { + const move = me.clientX - base; + if (left + width + move < browserWidth) { + if (width + move > minWidth) { + this.applyTransformWidth(width + move); + } else { // 最小の幅より小さくなろうとした時 + this.applyTransformWidth(minWidth); + } + } else { // 右のはみ出し時 + this.applyTransformWidth(browserWidth - left); + } + }); + }, + + // 下ハンドル掴み時 + onBottomHandleMousedown(e) { + const main = this.$el as any; + + const base = e.clientY; + const height = parseInt(getComputedStyle(main, '').height, 10); + const top = parseInt(getComputedStyle(main, '').top, 10); + const browserHeight = window.innerHeight; + + // 動かした時 + dragListen(me => { + const move = me.clientY - base; + if (top + height + move < browserHeight) { + if (height + move > minHeight) { + this.applyTransformHeight(height + move); + } else { // 最小の高さより小さくなろうとした時 + this.applyTransformHeight(minHeight); + } + } else { // 下のはみ出し時 + this.applyTransformHeight(browserHeight - top); + } + }); + }, + + // 左ハンドル掴み時 + onLeftHandleMousedown(e) { + const main = this.$el as any; + + const base = e.clientX; + const width = parseInt(getComputedStyle(main, '').width, 10); + const left = parseInt(getComputedStyle(main, '').left, 10); + + // 動かした時 + dragListen(me => { + const move = me.clientX - base; + if (left + move > 0) { + if (width + -move > minWidth) { + this.applyTransformWidth(width + -move); + this.applyTransformLeft(left + move); + } else { // 最小の幅より小さくなろうとした時 + this.applyTransformWidth(minWidth); + this.applyTransformLeft(left + (width - minWidth)); + } + } else { // 左のはみ出し時 + this.applyTransformWidth(left + width); + this.applyTransformLeft(0); + } + }); + }, + + // 左上ハンドル掴み時 + onTopLeftHandleMousedown(e) { + this.onTopHandleMousedown(e); + this.onLeftHandleMousedown(e); + }, + + // 右上ハンドル掴み時 + onTopRightHandleMousedown(e) { + this.onTopHandleMousedown(e); + this.onRightHandleMousedown(e); + }, + + // 右下ハンドル掴み時 + onBottomRightHandleMousedown(e) { + this.onBottomHandleMousedown(e); + this.onRightHandleMousedown(e); + }, + + // 左下ハンドル掴み時 + onBottomLeftHandleMousedown(e) { + this.onBottomHandleMousedown(e); + this.onLeftHandleMousedown(e); + }, + + // 高さを適用 + applyTransformHeight(height) { + (this.$el as any).style.height = height + 'px'; + }, + + // 幅を適用 + applyTransformWidth(width) { + (this.$el as any).style.width = width + 'px'; + }, + + // Y座標を適用 + applyTransformTop(top) { + (this.$el as any).style.top = top + 'px'; + }, + + // X座標を適用 + applyTransformLeft(left) { + (this.$el as any).style.left = left + 'px'; + }, + + onBrowserResize() { + const main = this.$el as any; + const position = main.getBoundingClientRect(); + const browserWidth = window.innerWidth; + const browserHeight = window.innerHeight; + const windowWidth = main.offsetWidth; + const windowHeight = main.offsetHeight; + if (position.left < 0) main.style.left = 0; // 左はみ出し + if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し + if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し + if (position.top < 0) main.style.top = 0; // 上はみ出し + } + } +}); +</script> + +<style lang="scss" scoped> +.window-enter-active, .window-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.window-enter-from, .window-leave-to { + pointer-events: none; + opacity: 0; + transform: scale(0.9); +} + +.ebkgocck { + position: fixed; + top: 0; + left: 0; + z-index: 5000; + + > .body { + overflow: hidden; + display: flex; + flex-direction: column; + contain: content; + width: 100%; + height: 100%; + + --section-padding: 16px; + + > .header { + $height: 50px; + display: flex; + position: relative; + flex-shrink: 0; + box-shadow: 0px 1px var(--divider); + cursor: move; + user-select: none; + height: $height; + + > ::v-deep(button) { + height: $height; + width: $height; + + &:hover { + color: var(--fgHighlighted); + } + } + + > .title { + flex: 1; + position: relative; + line-height: $height; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + > .body { + flex: 1; + overflow: auto; + } + } + + > .handle { + $size: 8px; + + position: absolute; + + &.top { + top: -($size); + left: 0; + width: 100%; + height: $size; + cursor: ns-resize; + } + + &.right { + top: 0; + right: -($size); + width: $size; + height: 100%; + cursor: ew-resize; + } + + &.bottom { + bottom: -($size); + left: 0; + width: 100%; + height: $size; + cursor: ns-resize; + } + + &.left { + top: 0; + left: -($size); + width: $size; + height: 100%; + cursor: ew-resize; + } + + &.top-left { + top: -($size); + left: -($size); + width: $size * 2; + height: $size * 2; + cursor: nwse-resize; + } + + &.top-right { + top: -($size); + right: -($size); + width: $size * 2; + height: $size * 2; + cursor: nesw-resize; + } + + &.bottom-right { + bottom: -($size); + right: -($size); + width: $size * 2; + height: $size * 2; + cursor: nwse-resize; + } + + &.bottom-left { + bottom: -($size); + left: -($size); + width: $size * 2; + height: $size * 2; + cursor: nesw-resize; + } + } +} +</style> diff --git a/src/client/components/uploader.vue b/src/client/components/upload.vue index 2d28bc84ac..2ba2186f57 100644 --- a/src/client/components/uploader.vue +++ b/src/client/components/upload.vue @@ -1,104 +1,48 @@ <template> -<div class="mk-uploader"> +<div class="mk-uploader _acrylic"> <ol v-if="uploads.length > 0"> <li v-for="ctx in uploads" :key="ctx.id"> <div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div> <div class="top"> - <p class="name"><fa :icon="faSpinner" pulse/>{{ ctx.name }}</p> + <p class="name"><Fa :icon="faSpinner" pulse/>{{ ctx.name }}</p> <p class="status"> - <span class="initing" v-if="ctx.progressValue === undefined">{{ $t('waiting') }}<mk-ellipsis/></span> + <span class="initing" v-if="ctx.progressValue === undefined">{{ $t('waiting') }}<MkEllipsis/></span> <span class="kb" v-if="ctx.progressValue !== undefined">{{ String(Math.floor(ctx.progressValue / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progressMax / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span> <span class="percentage" v-if="ctx.progressValue !== undefined">{{ Math.floor((ctx.progressValue / ctx.progressMax) * 100) }}</span> </p> </div> - <progress :value="ctx.progressValue" :max="ctx.progressMax" :class="{ initing: ctx.progressValue === undefined, waiting: ctx.progressValue !== undefined && ctx.progressValue === ctx.progressMax }"></progress> + <progress :value="ctx.progressValue || 0" :max="ctx.progressMax || 0" :class="{ initing: ctx.progressValue === undefined, waiting: ctx.progressValue !== undefined && ctx.progressValue === ctx.progressMax }"></progress> </li> </ol> </div> </template> <script lang="ts"> -import Vue from 'vue'; -import { apiUrl } from '../config'; -//import getMD5 from '../../scripts/get-md5'; +import { defineComponent } from 'vue'; import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ data() { return { - uploads: [], + uploads: os.uploads, faSpinner }; }, - 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', - progressMax: undefined, - progressValue: 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) { - ctx.progressMax = e.total; - ctx.progressValue = e.loaded; - } - }; - - xhr.send(data); - } - reader.readAsArrayBuffer(file); - } - } }); </script> <style lang="scss" scoped> .mk-uploader { - overflow: auto; + position: fixed; + z-index: 10000; + right: 16px; + width: 260px; + top: 32px; + padding: 16px 20px; + pointer-events: none; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + border-radius: 8px; } .mk-uploader:empty { display: none; diff --git a/src/client/components/url-preview-popup.vue b/src/client/components/url-preview-popup.vue index 6d00d18607..0a402f793f 100644 --- a/src/client/components/url-preview-popup.vue +++ b/src/client/components/url-preview-popup.vue @@ -1,14 +1,17 @@ <template> -<div class="fgmtyycl _panel _shadow" :style="{ top: top + 'px', left: left + 'px' }"> - <mk-url-preview :url="url"/> +<div class="fgmtyycl" :style="{ top: top + 'px', left: left + 'px' }"> + <transition name="zoom" @after-leave="$emit('closed')"> + <MkUrlPreview class="_popup _shadow" :url="url" v-if="showing"/> + </transition> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import MkUrlPreview from './url-preview.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkUrlPreview }, @@ -20,7 +23,11 @@ export default Vue.extend({ }, source: { required: true - } + }, + showing: { + type: Boolean, + required: true + }, }, data() { @@ -48,7 +55,6 @@ export default Vue.extend({ z-index: 11000; width: 500px; max-width: calc(90vw - 12px); - overflow: hidden; pointer-events: none; } </style> diff --git a/src/client/components/url-preview.vue b/src/client/components/url-preview.vue index a5052d6132..df02698b5d 100644 --- a/src/client/components/url-preview.vue +++ b/src/client/components/url-preview.vue @@ -1,6 +1,6 @@ <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('disablePlayer')"><fa icon="times"/></button> + <button class="disablePlayer" @click="playerEnabled = false" :title="$t('disablePlayer')"><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="tweetId && tweetExpanded" class="twitter" ref="twitter"> @@ -10,7 +10,7 @@ <transition name="zoom" mode="out-in"> <component :is="self ? 'router-link' : 'a'" :class="{ compact }" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url" v-if="!fetching"> <div class="thumbnail" v-if="thumbnail" :style="`background-image: url('${thumbnail}')`"> - <button class="_button" v-if="!playerEnabled && player.url" @click.prevent="playerEnabled = true" :title="$t('enablePlayer')"><fa :icon="faPlayCircle"/></button> + <button class="_button" v-if="!playerEnabled && player.url" @click.prevent="playerEnabled = true" :title="$t('enablePlayer')"><Fa :icon="faPlayCircle"/></button> </div> <article> <header> @@ -26,19 +26,20 @@ </transition> <div class="expandTweet" v-if="tweetId"> <a @click="tweetExpanded = true"> - <fa :icon="faTwitter"/> {{ $t('expandTweet') }} + <Fa :icon="faTwitter"/> {{ $t('expandTweet') }} </a> </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faPlayCircle } from '@fortawesome/free-regular-svg-icons'; import { faTwitter } from '@fortawesome/free-brands-svg-icons'; -import { url as local, lang } from '../config'; +import { url as local, lang } from '@/config'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { url: { type: String, @@ -135,7 +136,7 @@ export default Vue.extend({ }, }, - beforeDestroy() { + beforeUnmount() { (window as any).removeEventListener('message', this.adjustTweetHeight); }, }); diff --git a/src/client/components/url.vue b/src/client/components/url.vue index 0a5a5bc508..649ce5fa24 100644 --- a/src/client/components/url.vue +++ b/src/client/components/url.vue @@ -14,19 +14,19 @@ <span class="pathname" v-if="pathname != ''">{{ self ? pathname.substr(1) : pathname }}</span> <span class="query">{{ query }}</span> <span class="hash">{{ hash }}</span> - <fa :icon="faExternalLinkSquareAlt" v-if="target === '_blank'" class="icon"/> + <Fa :icon="faExternalLinkSquareAlt" v-if="target === '_blank'" class="icon"/> </component> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons'; import { toUnicode as decodePunycode } from 'punycode'; -import { url as local } from '../config'; -import MkUrlPreview from './url-preview-popup.vue'; -import { isDeviceTouch } from '../scripts/is-device-touch'; +import { url as local } from '@/config'; +import { isDeviceTouch } from '@/scripts/is-device-touch'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { url: { type: String, @@ -53,7 +53,7 @@ export default Vue.extend({ showTimer: null, hideTimer: null, checkTimer: null, - preview: null, + close: null, faExternalLinkSquareAlt }; }, @@ -67,29 +67,28 @@ export default Vue.extend({ this.hash = decodeURIComponent(url.hash); }, methods: { - showPreview() { + async showPreview() { if (!document.body.contains(this.$el)) return; - if (this.preview) return; + if (this.close) return; - this.preview = new MkUrlPreview({ - parent: this, - propsData: { - url: this.url, - source: this.$el - } - }).$mount(); + const { dispose } = os.popup(await import('@/components/url-preview-popup.vue'), { + url: this.url, + source: this.$el + }); - document.body.appendChild(this.preview.$el); + this.close = () => { + dispose(); + }; this.checkTimer = setInterval(() => { if (!document.body.contains(this.$el)) this.closePreview(); }, 1000); }, closePreview() { - if (this.preview) { + if (this.close) { clearInterval(this.checkTimer); - this.preview.destroyDom(); - this.preview = null; + this.close(); + this.close = null; } }, onMouseover() { diff --git a/src/client/components/user-info.vue b/src/client/components/user-info.vue new file mode 100644 index 0000000000..893747b7c4 --- /dev/null +++ b/src/client/components/user-info.vue @@ -0,0 +1,144 @@ +<template> +<div class="_panel vjnjpkug"> + <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div> + <MkAvatar class="avatar" :user="user" :disable-preview="true"/> + <div class="title"> + <router-link class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></router-link> + <p class="username"><MkAcct :user="user"/></p> + </div> + <div class="description"> + <div class="mfm" v-if="user.description"> + <Mfm :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> + </div> + <span v-else style="opacity: 0.7;">{{ $t('noAccountDescription') }}</span> + </div> + <div class="status"> + <div> + <p>{{ $t('notes') }}</p><span>{{ user.notesCount }}</span> + </div> + <div> + <p>{{ $t('following') }}</p><span>{{ user.followingCount }}</span> + </div> + <div> + <p>{{ $t('followers') }}</p><span>{{ user.followersCount }}</span> + </div> + </div> + <MkFollowButton class="koudoku-button" v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" mini/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import parseAcct from '../../misc/acct/parse'; +import MkFollowButton from './follow-button.vue'; +import { userPage } from '../filters/user'; + +export default defineComponent({ + components: { + MkFollowButton + }, + + props: { + user: { + type: Object, + required: true + }, + }, + + data() { + return { + }; + }, + + methods: { + userPage, + parseAcct, + } +}); +</script> + +<style lang="scss" scoped> +.vjnjpkug { + position: relative; + + > .banner { + height: 84px; + background-color: rgba(0, 0, 0, 0.1); + background-size: cover; + background-position: center; + } + + > .avatar { + display: block; + position: absolute; + top: 62px; + left: 13px; + z-index: 2; + width: 58px; + height: 58px; + border: solid 4px var(--panel); + } + + > .title { + display: block; + padding: 10px 0 10px 88px; + + > .name { + display: inline-block; + margin: 0; + font-weight: bold; + line-height: 16px; + word-break: break-all; + } + + > .username { + display: block; + margin: 0; + line-height: 16px; + font-size: 0.8em; + color: var(--text); + opacity: 0.7; + } + } + + > .description { + padding: 16px; + font-size: 0.8em; + border-top: solid 1px var(--divider); + + > .mfm { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } + } + + > .status { + padding: 10px 16px; + border-top: solid 1px var(--divider); + + > 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-list.vue b/src/client/components/user-list.vue index 0204cf9d06..0d58a2672a 100644 --- a/src/client/components/user-list.vue +++ b/src/client/components/user-list.vue @@ -1,44 +1,28 @@ <template> -<mk-container :body-togglable="true" :expanded="expanded"> - <template #header><slot></slot></template> +<MkError v-if="error" @retry="init()"/> - <mk-error v-if="error" @retry="init()"/> - - <div class="efvhhmdq"> - <div class="no-users" v-if="empty"> - <p>{{ $t('noUsers') }}</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> - <mk-follow-button class="koudoku-button" v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" mini/> - </div> - <button class="more" ref="loadMore" :class="{ fetching: moreFetching }" v-show="more" :disabled="moreFetching"> - <template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template>{{ moreFetching ? $t('loading') : $t('loadMore') }} - </button> +<div v-else class="efvhhmdq"> + <div class="no-users" v-if="empty"> + <p>{{ $t('noUsers') }}</p> + </div> + <div class="users"> + <MkUserInfo class="user" v-for="user in users" :user="user" :key="user.id"/> </div> -</mk-container> + <button class="more" v-appear="$store.state.device.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :class="{ fetching: moreFetching }" v-show="more" :disabled="moreFetching"> + <template v-if="moreFetching"><Fa icon="spinner" pulse fixed-width/></template>{{ moreFetching ? $t('loading') : $t('loadMore') }} + </button> +</div> </template> <script lang="ts"> -import Vue from 'vue'; -import paging from '../scripts/paging'; -import MkContainer from './ui/container.vue'; -import MkFollowButton from './follow-button.vue'; +import { defineComponent } from 'vue'; +import paging from '@/scripts/paging'; +import MkUserInfo from './user-info.vue'; +import { userPage } from '../filters/user'; -export default Vue.extend({ +export default defineComponent({ components: { - MkContainer, - MkFollowButton, + MkUserInfo, }, mixins: [ @@ -62,6 +46,10 @@ export default Vue.extend({ users() { return this.extract ? this.extract(this.items) : this.items; } + }, + + methods: { + userPage } }); </script> @@ -72,52 +60,10 @@ export default Vue.extend({ 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; - } + > .users { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: var(--margin); } > .more { diff --git a/src/client/components/user-menu.vue b/src/client/components/user-menu.vue deleted file mode 100644 index cbfa7b346d..0000000000 --- a/src/client/components/user-menu.vue +++ /dev/null @@ -1,258 +0,0 @@ -<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, faUsers, faMicrophoneSlash, faPlug } from '@fortawesome/free-solid-svg-icons'; -import { faSnowflake, faEnvelope } from '@fortawesome/free-regular-svg-icons'; -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({ - 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 - }, this.$store.state.i.id != this.user.id ? { - icon: faUsers, - text: this.$t('inviteToGroup'), - action: this.inviteGroup - } : undefined] 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.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 - }]); - } - } - - 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'); - } - }]); - } - - if (this.$store.state.userActions.length > 0) { - menu = menu.concat([null, ...this.$store.state.userActions.map(action => ({ - icon: faPlug, - text: action.title, - action: () => { - action.handler(this.user); - } - }))]); - } - - 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 inviteGroup() { - const groups = await this.$root.api('users/groups/owned'); - if (groups.length === 0) { - this.$root.dialog({ - type: 'error', - text: this.$t('youHaveNoGroups') - }); - return; - } - const { canceled, result: groupId } = await this.$root.dialog({ - type: null, - title: this.$t('group'), - select: { - items: groups.map(group => ({ - value: group.id, text: group.name - })) - }, - showCancelButton: true - }); - if (canceled) return; - this.$root.api('users/groups/invite', { - groupId: groupId, - 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 toggleSilence() { - if (!await this.getConfirmed(this.$t(this.user.isSilenced ? 'unsilenceConfirm' : 'silenceConfirm'))) 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', - 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-name.vue b/src/client/components/user-name.vue index 425cb587c4..bc93a8ea30 100644 --- a/src/client/components/user-name.vue +++ b/src/client/components/user-name.vue @@ -1,11 +1,11 @@ <template> -<mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/> +<Mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; -export default Vue.extend({ +export default defineComponent({ props: { user: { type: Object, diff --git a/src/client/components/user-preview.vue b/src/client/components/user-preview.vue index 410de6b488..d1a11dc790 100644 --- a/src/client/components/user-preview.vue +++ b/src/client/components/user-preview.vue @@ -1,44 +1,55 @@ <template> -<transition name="popup" appear @after-leave="() => { $emit('closed'); destroyDom(); }"> - <div v-if="show" class="fxxzrfni _panel _shadow" 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> +<transition name="popup" appear @after-leave="$emit('closed')"> + <div v-if="showing" class="fxxzrfni _popup _shadow" :style="{ top: top + 'px', left: left + 'px' }" @mouseover="() => { $emit('mouseover'); }" @mouseleave="() => { $emit('mouseleave'); }"> + <div v-if="fetched" class="info"> + <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div> + <MkAvatar class="avatar" :user="user" :disable-preview="true"/> + <div class="title"> + <router-link class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></router-link> + <p class="username"><MkAcct :user="user"/></p> </div> - <div> - <p>{{ $t('following') }}</p><span>{{ u.followingCount }}</span> + <div class="description"> + <Mfm v-if="user.description" :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> </div> - <div> - <p>{{ $t('followers') }}</p><span>{{ u.followersCount }}</span> + <div class="status"> + <div> + <p>{{ $t('notes') }}</p><span>{{ user.notesCount }}</span> + </div> + <div> + <p>{{ $t('following') }}</p><span>{{ user.followingCount }}</span> + </div> + <div> + <p>{{ $t('followers') }}</p><span>{{ user.followersCount }}</span> + </div> </div> + <MkFollowButton class="koudoku-button" v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" mini/> + </div> + <div v-else> + <MkLoading/> </div> - <mk-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 { defineComponent } from 'vue'; import parseAcct from '../../misc/acct/parse'; import MkFollowButton from './follow-button.vue'; +import { userPage } from '../filters/user'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkFollowButton }, props: { - user: { - type: [Object, String], + showing: { + type: Boolean, + required: true + }, + q: { + type: String, required: true }, source: { @@ -46,29 +57,30 @@ export default Vue.extend({ } }, + emits: ['closed', 'mouseover', 'mouseleave'], + data() { return { - u: null, - show: false, - closed: false, + user: null, + fetched: false, top: 0, left: 0, }; }, mounted() { - if (typeof this.user == 'object') { - this.u = this.user; - this.show = true; + if (typeof this.q == 'object') { + this.user = this.q; + this.fetched = true; } else { - const query = this.user.startsWith('@') ? - parseAcct(this.user.substr(1)) : - { userId: this.user }; + const query = this.q.startsWith('@') ? + parseAcct(this.q.substr(1)) : + { userId: this.q }; - this.$root.api('users/show', query).then(user => { - if (this.closed) return; - this.u = user; - this.show = true; + os.api('users/show', query).then(user => { + if (!this.showing) return; + this.user = user; + this.fetched = true; }); } @@ -81,11 +93,7 @@ export default Vue.extend({ }, methods: { - close() { - this.closed = true; - this.show = false; - if (this.$refs.content) (this.$refs.content as any).style.pointerEvents = 'none'; - } + userPage } }); </script> @@ -94,7 +102,7 @@ export default Vue.extend({ .popup-enter-active, .popup-leave-active { transition: opacity 0.3s, transform 0.3s !important; } -.popup-enter, .popup-leave-to { +.popup-enter-from, .popup-leave-to { opacity: 0; transform: scale(0.9); } @@ -104,78 +112,81 @@ export default Vue.extend({ z-index: 11000; width: 300px; overflow: hidden; + transform-origin: center top; - > .banner { - height: 84px; - background-color: rgba(0, 0, 0, 0.1); - background-size: cover; - background-position: center; - } + > .info { + > .banner { + height: 84px; + background-color: rgba(0, 0, 0, 0.1); + background-size: cover; + background-position: center; + } - > .avatar { - display: block; - position: absolute; - top: 62px; - left: 13px; - z-index: 2; - width: 58px; - height: 58px; - border: solid 3px var(--face); - border-radius: 8px; - } + > .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; - > .title { - display: block; - padding: 8px 0 8px 82px; + > .name { + display: inline-block; + margin: 0; + font-weight: bold; + line-height: 16px; + word-break: break-all; + } - > .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; + } } - > .username { - display: block; - margin: 0; - line-height: 16px; + > .description { + padding: 0 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; - > .status { - padding: 8px 16px; + > div { + display: inline-block; + width: 33%; - > div { - display: inline-block; - width: 33%; + > p { + margin: 0; + font-size: 0.7em; + color: var(--text); + } - > p { - margin: 0; - font-size: 0.7em; - color: var(--text); - } - - > span { - font-size: 1em; - color: var(--accent); + > span { + font-size: 1em; + color: var(--accent); + } } } - } - > .koudoku-button { - position: absolute; - top: 8px; - right: 8px; + > .koudoku-button { + position: absolute; + top: 8px; + right: 8px; + } } } </style> diff --git a/src/client/components/user-select.vue b/src/client/components/user-select-dialog.vue index a0d1f3432a..e7bfcafbb3 100644 --- a/src/client/components/user-select.vue +++ b/src/client/components/user-select-dialog.vue @@ -1,39 +1,54 @@ <template> -<x-window ref="window" @closed="() => { $emit('closed'); destroyDom(); }" :with-ok-button="true" :ok-button-disabled="selected == null" @ok="ok()"> +<XModalWindow ref="dialog" + :with-ok-button="true" + :ok-button-disabled="selected == null" + @click="cancel()" + @close="cancel()" + @ok="ok()" + @closed="$emit('closed')" +> <template #header>{{ $t('selectUser') }}</template> - <div class="tbhwbxda"> + <div class="tbhwbxda _section"> <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> + <MkInput v-model:value="username" class="input" @update:value="search" ref="username"><span>{{ $t('username') }}</span><template #prefix>@</template></MkInput> + <MkInput v-model:value="host" class="input" @update:value="search"><span>{{ $t('host') }}</span><template #prefix>@</template></MkInput> </div> - <div class="users"> + </div> + <div class="tbhwbxda _section" :style="users.length > 0 ? 'padding: 0;' : ''"> + <div class="users" v-if="users.length > 0"> <div class="user" v-for="user in users" :key="user.id" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()"> - <mk-avatar :user="user" class="avatar" :disable-link="true"/> + <MkAvatar :user="user" class="avatar" :disable-link="true"/> <div class="body"> - <mk-user-name :user="user" class="name"/> - <mk-acct :user="user" class="acct"/> + <MkUserName :user="user" class="name"/> + <MkAcct :user="user" class="acct"/> </div> </div> </div> + <div v-else class="empty"> + <span>{{ $t('noUsers') }}</span> + </div> </div> -</x-window> +</XModalWindow> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faTimes, faCheck } from '@fortawesome/free-solid-svg-icons'; import MkInput from './ui/input.vue'; -import XWindow from './window.vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkInput, - XWindow, + XModalWindow, }, props: { }, + emits: ['ok', 'cancel', 'closed'], + data() { return { username: '', @@ -58,7 +73,7 @@ export default Vue.extend({ this.users = []; return; } - this.$root.api('users/search-by-username-and-host', { + os.api('users/search-by-username-and-host', { username: this.username, host: this.host, limit: 10, @@ -72,13 +87,14 @@ export default Vue.extend({ this.$refs.username.focus(); }, - close() { - this.$refs.window.close(); + ok() { + this.$emit('ok', this.selected); + this.$refs.dialog.close(); }, - ok() { - this.$emit('selected', this.selected); - this.close(); + cancel() { + this.$emit('cancel'); + this.$refs.dialog.close(); }, } }); @@ -90,10 +106,8 @@ export default Vue.extend({ flex-direction: column; overflow: auto; height: 100%; - - > .inputs { - margin-top: 16px; + > .inputs { > .input { display: inline-block; width: 50%; @@ -104,11 +118,12 @@ export default Vue.extend({ > .users { flex: 1; overflow: auto; + padding: 8px 0; > .user { display: flex; align-items: center; - padding: 8px 16px; + padding: 8px var(--section-padding); font-size: 14px; &:hover { @@ -145,5 +160,10 @@ export default Vue.extend({ } } } + + > .empty { + opacity: 0.7; + text-align: center; + } } </style> diff --git a/src/client/components/users-dialog.vue b/src/client/components/users-dialog.vue index 575d031182..c8ca93703d 100644 --- a/src/client/components/users-dialog.vue +++ b/src/client/components/users-dialog.vue @@ -1,43 +1,37 @@ <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> +<div class="mk-users-dialog"> + <div class="header"> + <span>{{ title }}</span> + <button class="_button" @click="close()"><Fa :icon="faTimes"/></button> + </div> - <div class="users"> - <router-link v-for="item in items" class="user" :key="item.id" :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> - </div> - <button class="more _button" ref="loadMore" v-show="more" @click="fetchMore" :disabled="moreFetching"> - <template v-if="!moreFetching">{{ $t('loadMore') }}</template> - <template v-if="moreFetching"><fa :icon="faSpinner" pulse fixed-width/></template> - </button> + <div class="users"> + <router-link v-for="item in items" class="user" :key="item.id" :to="userPage(extract ? extract(item) : item)"> + <MkAvatar :user="extract ? extract(item) : item" class="avatar" :disable-link="true"/> + <div class="body"> + <MkUserName :user="extract ? extract(item) : item" class="name"/> + <MkAcct :user="extract ? extract(item) : item" class="acct"/> + </div> + </router-link> + </div> + <button class="more _button" v-appear="$store.state.device.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :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> + <p class="empty" v-if="empty">{{ $t('noUsers') }}</p> - <mk-error v-if="error" @retry="init()"/> - </div> -</x-modal> + <MkError v-if="error" @retry="init()"/> +</div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faTimes } from '@fortawesome/free-solid-svg-icons'; -import paging from '../scripts/paging'; -import XModal from './modal.vue'; - -export default Vue.extend({ - components: { - XModal, - }, +import paging from '@/scripts/paging'; +import { userPage } from '../filters/user'; +export default defineComponent({ mixins: [ paging({}), ], @@ -61,9 +55,7 @@ export default Vue.extend({ }, methods: { - close() { - this.$refs.modal.close(); - }, + userPage } }); </script> diff --git a/src/client/components/visibility-chooser.vue b/src/client/components/visibility-picker.vue index 75368cd60d..06901378b7 100644 --- a/src/client/components/visibility-chooser.vue +++ b/src/client/components/visibility-picker.vue @@ -1,29 +1,29 @@ <template> -<x-popup :source="source" ref="popup" @closed="closed"> - <div class="gqyayizv"> +<MkModal ref="modal" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')"> + <div class="gqyayizv _popup"> <button class="_button" @click="choose('public')" :class="{ active: v == 'public' }" data-index="1" key="public"> - <div><fa :icon="faGlobe"/></div> + <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="2" key="home"> - <div><fa :icon="faHome"/></div> + <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="3" key="followers"> - <div><fa :icon="faUnlock"/></div> + <div><Fa :icon="faUnlock"/></div> <div> <span>{{ $t('_visibility.followers') }}</span> <span>{{ $t('_visibility.followersDescription') }}</span> </div> </button> <button :disabled="localOnly" class="_button" @click="choose('specified')" :class="{ active: v == 'specified' }" data-index="4" key="specified"> - <div><fa :icon="faEnvelope"/></div> + <div><Fa :icon="faEnvelope"/></div> <div> <span>{{ $t('_visibility.specified') }}</span> <span>{{ $t('_visibility.specifiedDescription') }}</span> @@ -31,31 +31,28 @@ </button> <div class="divider"></div> <button class="_button localOnly" @click="localOnly = !localOnly" :class="{ active: localOnly }" data-index="5" key="localOnly"> - <div><fa :icon="faBiohazard"/></div> + <div><Fa :icon="faBiohazard"/></div> <div> <span>{{ $t('_visibility.localOnly') }}</span> <span>{{ $t('_visibility.localOnlyDescription') }}</span> </div> - <div><fa :icon="localOnly ? faToggleOn : faToggleOff"/></div> + <div><Fa :icon="localOnly ? faToggleOn : faToggleOff" :key="localOnly"/></div> </button> </div> -</x-popup> +</MkModal> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faGlobe, faUnlock, faHome, faBiohazard, faToggleOn, faToggleOff } from '@fortawesome/free-solid-svg-icons'; import { faEnvelope } from '@fortawesome/free-regular-svg-icons'; -import XPopup from './popup.vue'; +import MkModal from '@/components/ui/modal.vue'; -export default Vue.extend({ +export default defineComponent({ components: { - XPopup + MkModal, }, props: { - source: { - required: true - }, currentVisibility: { type: String, required: false @@ -63,8 +60,12 @@ export default Vue.extend({ currentLocalOnly: { type: Boolean, required: false - } + }, + src: { + required: false + }, }, + emits: ['change-visibility', 'change-local-only', 'closed'], data() { return { v: this.$store.state.settings.rememberNoteVisibility ? this.$store.state.deviceUser.visibility : (this.currentVisibility || this.$store.state.settings.defaultNoteVisibility), @@ -72,20 +73,22 @@ export default Vue.extend({ faGlobe, faUnlock, faEnvelope, faHome, faBiohazard, faToggleOn, faToggleOff } }, + watch: { + localOnly() { + this.$emit('change-local-only', this.localOnly); + } + }, methods: { choose(visibility) { + this.v = visibility; if (this.$store.state.settings.rememberNoteVisibility) { this.$store.commit('deviceUser/setVisibility', visibility); } - this.$emit('chosen', { visibility, localOnly: this.localOnly }); - this.destroyDom(); + this.$emit('change-visibility', visibility); + this.$nextTick(() => { + this.$refs.modal.close(); + }); }, - closed() { - this.$emit('closed'); - // localOnly フラグの更新の為に chosen イベントも呼ぶ - this.choose(this.v); - this.destroyDom(); - } } }); </script> diff --git a/src/client/config.ts b/src/client/config.ts index badb695245..ac8d7d9528 100644 --- a/src/client/config.ts +++ b/src/client/config.ts @@ -1,9 +1,5 @@ import { clientDb, entries } from './db'; -declare const _LANGS_: string[]; -declare const _VERSION_: string; -declare const _ENV_: string; - const address = new URL(location.href); const siteName = (document.querySelector('meta[property="og:site_name"]') as HTMLMetaElement)?.content; @@ -16,6 +12,5 @@ export const lang = localStorage.getItem('lang'); export const langs = _LANGS_; export const getLocale = async () => Object.fromEntries((await entries(clientDb.i18n)) as [string, string][]); export const version = _VERSION_; -export const env = _ENV_; export const instanceName = siteName === 'Misskey' ? null : siteName; export const deckmode = localStorage.getItem('deckmode') === 'true'; diff --git a/src/client/directives/appear.ts b/src/client/directives/appear.ts new file mode 100644 index 0000000000..a504d11ef9 --- /dev/null +++ b/src/client/directives/appear.ts @@ -0,0 +1,22 @@ +import { Directive } from 'vue'; + +export default { + mounted(src, binding, vn) { + const fn = binding.value; + if (fn == null) return; + + const observer = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) { + fn(); + } + }); + + observer.observe(src); + + src._observer_ = observer; + }, + + unmounted(src, binding, vn) { + if (src._observer_) src._observer_.disconnect(); + } +} as Directive; diff --git a/src/client/scripts/hotkey.ts b/src/client/directives/hotkey.ts index 5f73aa58b9..a1c49f0074 100644 --- a/src/client/scripts/hotkey.ts +++ b/src/client/directives/hotkey.ts @@ -1,4 +1,5 @@ -import keyCode from './keycode'; +import { Directive } from 'vue'; +import keyCode from '../scripts/keycode'; import { concat } from '../../prelude/array'; type pattern = { @@ -65,52 +66,48 @@ function match(e: KeyboardEvent, patterns: action['patterns']): boolean { } export default { - install(Vue) { - Vue.directive('hotkey', { - bind(el, binding) { - el._hotkey_global = binding.modifiers.global === true; + mounted(el, binding) { + el._hotkey_global = binding.modifiers.global === true; - const actions = getKeyMap(binding.value); + const actions = getKeyMap(binding.value); - // flatten - const reservedKeys = concat(actions.map(a => a.patterns)); + // flatten + const reservedKeys = concat(actions.map(a => a.patterns)); - el._misskey_reservedKeys = reservedKeys; + el._misskey_reservedKeys = reservedKeys; - el._keyHandler = (e: KeyboardEvent) => { - const targetReservedKeys = document.activeElement ? ((document.activeElement as any)._misskey_reservedKeys || []) : []; - if (document.activeElement && ignoreElemens.some(el => document.activeElement.matches(el))) return; - if (document.activeElement && document.activeElement.attributes['contenteditable']) return; + el._keyHandler = (e: KeyboardEvent) => { + const targetReservedKeys = document.activeElement ? ((document.activeElement as any)._misskey_reservedKeys || []) : []; + if (document.activeElement && ignoreElemens.some(el => document.activeElement.matches(el))) return; + if (document.activeElement && document.activeElement.attributes['contenteditable']) return; - for (const action of actions) { - const matched = match(e, action.patterns); + for (const action of actions) { + const matched = match(e, action.patterns); - if (matched) { - if (!action.allowRepeat && e.repeat) return; - if (el._hotkey_global && match(e, targetReservedKeys)) return; + if (matched) { + if (!action.allowRepeat && e.repeat) return; + if (el._hotkey_global && match(e, targetReservedKeys)) return; - e.preventDefault(); - e.stopPropagation(); - action.callback(e); - break; - } - } - }; - - if (el._hotkey_global) { - document.addEventListener('keydown', el._keyHandler); - } else { - el.addEventListener('keydown', el._keyHandler); - } - }, - - unbind(el) { - if (el._hotkey_global) { - document.removeEventListener('keydown', el._keyHandler); - } else { - el.removeEventListener('keydown', el._keyHandler); + e.preventDefault(); + e.stopPropagation(); + action.callback(e); + break; } } - }); + }; + + if (el._hotkey_global) { + document.addEventListener('keydown', el._keyHandler); + } else { + el.addEventListener('keydown', el._keyHandler); + } + }, + + unmounted(el) { + if (el._hotkey_global) { + document.removeEventListener('keydown', el._keyHandler); + } else { + el.removeEventListener('keydown', el._keyHandler); + } } -}; +} as Directive; diff --git a/src/client/directives/index.ts b/src/client/directives/index.ts index 8cd5ed464d..474c6b4eed 100644 --- a/src/client/directives/index.ts +++ b/src/client/directives/index.ts @@ -1,14 +1,18 @@ -import Vue from 'vue'; +import { App } from 'vue'; import userPreview from './user-preview'; -import autocomplete from './autocomplete'; import size from './size'; import particle from './particle'; import tooltip from './tooltip'; +import hotkey from './hotkey'; +import appear from './appear'; -Vue.directive('autocomplete', autocomplete); -Vue.directive('userPreview', userPreview); -Vue.directive('user-preview', userPreview); -Vue.directive('size', size); -Vue.directive('particle', particle); -Vue.directive('tooltip', tooltip); +export default function(app: App) { + app.directive('userPreview', userPreview); + app.directive('user-preview', userPreview); + app.directive('size', size); + app.directive('particle', particle); + app.directive('tooltip', tooltip); + app.directive('hotkey', hotkey); + app.directive('appear', appear); +} diff --git a/src/client/directives/particle.ts b/src/client/directives/particle.ts index 4fb2a7948e..c90df89a5e 100644 --- a/src/client/directives/particle.ts +++ b/src/client/directives/particle.ts @@ -1,24 +1,18 @@ -import Particle from '../components/particle.vue'; +import Particle from '@/components/particle.vue'; +import { popup } from '@/os'; export default { - bind(el, binding, vn) { + mounted(el, binding, vn) { // 明示的に false であればバインドしない if (binding.value === false) return; + el.addEventListener('click', () => { const rect = el.getBoundingClientRect(); const x = rect.left + (el.clientWidth / 2); const y = rect.top + (el.clientHeight / 2); - const particle = new Particle({ - parent: vn.context, - propsData: { - x, - y - } - }).$mount(); - - document.body.appendChild(particle.$el); + popup(Particle, { x, y }, {}, 'end'); }); } }; diff --git a/src/client/directives/size.ts b/src/client/directives/size.ts index dbe26640b9..a72a97abcc 100644 --- a/src/client/directives/size.ts +++ b/src/client/directives/size.ts @@ -1,11 +1,11 @@ +import { Directive } from 'vue'; + +//const observers = new Map<Element, ResizeObserver>(); + export default { - inserted(src, binding, vn) { + mounted(src, binding, vn) { const query = binding.value; - // TODO: 要素をもらうというよりはカスタム幅算出関数をもらうようにしてcalcで都度呼び出して計算するようにした方が柔軟そう - // その場合はunbindの方も改修することを忘れずに - const el = query.el ? query.el() : src; - const addClass = (el: Element, cls: string) => { el.classList.add(cls); }; @@ -15,7 +15,10 @@ export default { }; const calc = () => { - const width = el.clientWidth; + const width = src.clientWidth; + + // 要素が(一時的に)DOMに存在しないときは計算スキップ + if (width === 0) return; if (query.max) { for (const v of query.max) { @@ -39,22 +42,27 @@ export default { calc(); - vn.context.$on('hook:activated', calc); + window.addEventListener('resize', calc); - const ro = new ResizeObserver((entries, observer) => { - calc(); - }); + // Vue3では使えなくなった + // 無くても大丈夫か...? + // TODO: ↑大丈夫じゃなかったので解決策を探す + //vn.context.$on('hook:activated', calc); - ro.observe(el); + //const ro = new ResizeObserver((entries, observer) => { + // calc(); + //}); - el._ro_ = ro; - }, - - unbind(src, binding, vn) { - const query = binding.value; + //ro.observe(el); - const el = query.el ? query.el() : src; + // TODO: 新たにプロパティを作るのをやめMapを使う + // ただメモリ的には↓の方が省メモリかもしれないので検討中 + //el._ro_ = ro; + src._calc_ = calc; + }, - el._ro_.unobserve(el); + unmounted(src, binding, vn) { + //el._ro_.unobserve(el); + window.removeEventListener('resize', src._calc_); } -}; +} as Directive; diff --git a/src/client/directives/tooltip.ts b/src/client/directives/tooltip.ts index 28d22fc024..f232ea47c7 100644 --- a/src/client/directives/tooltip.ts +++ b/src/client/directives/tooltip.ts @@ -1,53 +1,60 @@ -import MkTooltip from '../components/ui/tooltip.vue'; -import { isDeviceTouch } from '../scripts/is-device-touch'; +import { Directive, ref } from 'vue'; +import { isDeviceTouch } from '@/scripts/is-device-touch'; +import { popup } from '@/os'; const start = isDeviceTouch ? 'touchstart' : 'mouseover'; const end = isDeviceTouch ? 'touchend' : 'mouseleave'; export default { - bind(el: HTMLElement, binding, vn) { + mounted(el: HTMLElement, binding, vn) { const self = (el as any)._tooltipDirective_ = {} as any; self.text = binding.value as string; - self.tag = null; + self._close = null; self.showTimer = null; self.hideTimer = null; self.checkTimer = null; self.close = () => { - if (self.tag) { + if (self._close) { clearInterval(self.checkTimer); - self.tag.close(); - self.tag = null; + self._close(); + self._close = null; } }; - const show = e => { + const show = async e => { if (!document.body.contains(el)) return; - if (self.tag) return; + if (self._close) return; + if (self.text == null) return; - self.tag = new MkTooltip({ - parent: vn.context, - propsData: { - text: self.text, - source: el - } - }).$mount(); + const showing = ref(true); + popup(await import('@/components/ui/tooltip.vue'), { + showing, + text: self.text, + source: el + }, {}, 'closed'); - document.body.appendChild(self.tag.$el); + self._close = () => { + showing.value = false; + }; }; + el.addEventListener('selectstart', e => { + e.preventDefault(); + }); + el.addEventListener(start, () => { clearTimeout(self.showTimer); clearTimeout(self.hideTimer); self.showTimer = setTimeout(show, 300); - }); + }, { passive: true }); el.addEventListener(end, () => { clearTimeout(self.showTimer); clearTimeout(self.hideTimer); self.hideTimer = setTimeout(self.close, 300); - }); + }, { passive: true }); el.addEventListener('click', () => { clearTimeout(self.showTimer); @@ -55,8 +62,8 @@ export default { }); }, - unbind(el, binding, vn) { + unmounted(el, binding, vn) { const self = el._tooltipDirective_; clearInterval(self.checkTimer); }, -}; +} as Directive; diff --git a/src/client/directives/user-preview.ts b/src/client/directives/user-preview.ts index 4db0d67c4a..0fe05aca40 100644 --- a/src/client/directives/user-preview.ts +++ b/src/client/directives/user-preview.ts @@ -1,75 +1,118 @@ -import MkUserPreview from '../components/user-preview.vue'; +import { Directive, ref } from 'vue'; +import autobind from 'autobind-decorator'; +import { popup } from '@/os'; -export default { - bind(el: HTMLElement, binding, vn) { - const self = (el as any)._userPreviewDirective_ = {} as any; +export class UserPreview { + private el; + private user; + private showTimer; + private hideTimer; + private checkTimer; + private promise; + + constructor(el, user) { + this.el = el; + this.user = user; + + this.attach(); + } - self.user = binding.value; - self.tag = null; - self.showTimer = null; - self.hideTimer = null; - self.checkTimer = null; + @autobind + private async show() { + if (!document.body.contains(this.el)) return; + if (this.promise) return; - self.close = () => { - if (self.tag) { - clearInterval(self.checkTimer); - self.tag.close(); - self.tag = null; + const showing = ref(true); + + popup(await import('@/components/user-preview.vue'), { + showing, + q: this.user, + source: this.el + }, { + mouseover: () => { + clearTimeout(this.hideTimer); + }, + mouseleave: () => { + clearTimeout(this.showTimer); + this.hideTimer = setTimeout(this.close, 500); + }, + }, 'closed'); + + this.promise = { + cancel: () => { + showing.value = false; } }; - const show = () => { - if (!document.body.contains(el)) return; - if (self.tag) return; + this.checkTimer = setInterval(() => { + if (!document.body.contains(this.el)) { + clearTimeout(this.showTimer); + clearTimeout(this.hideTimer); + this.close(); + } + }, 1000); + } + + @autobind + private close() { + if (this.promise) { + clearInterval(this.checkTimer); + this.promise.cancel(); + this.promise = null; + } + } - self.tag = new MkUserPreview({ - parent: vn.context, - propsData: { - user: self.user, - source: el - } - }).$mount(); + @autobind + private onMouseover() { + clearTimeout(this.showTimer); + clearTimeout(this.hideTimer); + this.showTimer = setTimeout(this.show, 500); + } - self.tag.$on('mouseover', () => { - clearTimeout(self.hideTimer); - }); + @autobind + private onMouseleave() { + clearTimeout(this.showTimer); + clearTimeout(this.hideTimer); + this.hideTimer = setTimeout(this.close, 500); + } - self.tag.$on('mouseleave', () => { - clearTimeout(self.showTimer); - self.hideTimer = setTimeout(self.close, 500); - }); + @autobind + private onClick() { + clearTimeout(this.showTimer); + this.close(); + } - document.body.appendChild(self.tag.$el); + @autobind + public attach() { + this.el.addEventListener('mouseover', this.onMouseover); + this.el.addEventListener('mouseleave', this.onMouseleave); + this.el.addEventListener('click', this.onClick); + } - self.checkTimer = setInterval(() => { - if (!document.body.contains(el)) { - clearTimeout(self.showTimer); - clearTimeout(self.hideTimer); - self.close(); - } - }, 1000); - }; + @autobind + public detach() { + this.el.removeEventListener('mouseover', this.onMouseover); + this.el.removeEventListener('mouseleave', this.onMouseleave); + this.el.removeEventListener('click', this.onClick); + clearInterval(this.checkTimer); + } +} - el.addEventListener('mouseover', () => { - clearTimeout(self.showTimer); - clearTimeout(self.hideTimer); - self.showTimer = setTimeout(show, 500); - }); +export default { + mounted(el: HTMLElement, binding, vn) { + if (binding.value == null) return; - el.addEventListener('mouseleave', () => { - clearTimeout(self.showTimer); - clearTimeout(self.hideTimer); - self.hideTimer = setTimeout(self.close, 500); - }); + // TODO: 新たにプロパティを作るのをやめMapを使う + // ただメモリ的には↓の方が省メモリかもしれないので検討中 + const self = (el as any)._userPreviewDirective_ = {} as any; - el.addEventListener('click', () => { - clearTimeout(self.showTimer); - self.close(); - }); + self.preview = new UserPreview(el, binding.value); }, - unbind(el, binding, vn) { + unmounted(el, binding, vn) { + if (binding.value == null) return; + const self = el._userPreviewDirective_; - clearInterval(self.checkTimer); + self.preview.detach(); } -}; +} as Directive; diff --git a/src/client/filters/bytes.ts b/src/client/filters/bytes.ts index 5b5d966cfd..50e63534b6 100644 --- a/src/client/filters/bytes.ts +++ b/src/client/filters/bytes.ts @@ -1,6 +1,4 @@ -import Vue from 'vue'; - -Vue.filter('bytes', (v, digits = 0) => { +export default (v, digits = 0) => { if (v == null) return '?'; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; if (v == 0) return '0'; @@ -8,4 +6,4 @@ Vue.filter('bytes', (v, digits = 0) => { if (isMinus) v = -v; const i = Math.floor(Math.log(v) / Math.log(1024)); return (isMinus ? '-' : '') + (v / Math.pow(1024, i)).toFixed(digits).replace(/\.0+$/, '') + sizes[i]; -}); +}; diff --git a/src/client/filters/index.ts b/src/client/filters/index.ts deleted file mode 100644 index 1759c19c2c..0000000000 --- a/src/client/filters/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -require('./bytes'); -require('./number'); -require('./user'); -require('./note'); diff --git a/src/client/filters/note.ts b/src/client/filters/note.ts index 3c9c8b7485..5c000cf83b 100644 --- a/src/client/filters/note.ts +++ b/src/client/filters/note.ts @@ -1,5 +1,3 @@ -import Vue from 'vue'; - -Vue.filter('notePage', note => { +export default note => { return `/notes/${note.id}`; -}); +}; diff --git a/src/client/filters/number.ts b/src/client/filters/number.ts index 8c799d9442..880a848ca4 100644 --- a/src/client/filters/number.ts +++ b/src/client/filters/number.ts @@ -1,3 +1 @@ -import Vue from 'vue'; - -Vue.filter('number', n => n == null ? 'N/A' : n.toLocaleString()); +export default n => n == null ? 'N/A' : n.toLocaleString(); diff --git a/src/client/filters/user.ts b/src/client/filters/user.ts index e8f10c3db6..34ea488d98 100644 --- a/src/client/filters/user.ts +++ b/src/client/filters/user.ts @@ -1,16 +1,15 @@ -import Vue from 'vue'; import getAcct from '../../misc/acct/render'; import getUserName from '../../misc/get-user-name'; -import { url } from '../config'; +import { url } from '@/config'; -Vue.filter('acct', user => { +export const acct = user => { return getAcct(user); -}); +}; -Vue.filter('userName', user => { +export const userName = user => { return getUserName(user); -}); +}; -Vue.filter('userPage', (user, path?, absolute = false) => { - return `${absolute ? url : ''}/@${Vue.filter('acct')(user)}${(path ? `/${path}` : '')}`; -}); +export const userPage = (user, path?, absolute = false) => { + return `${absolute ? url : ''}/@${acct(user)}${(path ? `/${path}` : '')}`; +}; diff --git a/src/client/i18n.ts b/src/client/i18n.ts new file mode 100644 index 0000000000..6f2dd1a7d2 --- /dev/null +++ b/src/client/i18n.ts @@ -0,0 +1,36 @@ +import { createI18n } from 'vue-i18n'; +import { clientDb, get, count } from './db'; +import { setI18nContexts } from '@/scripts/set-i18n-contexts'; +import { version, langs, getLocale } from '@/config'; + +let _lang = localStorage.getItem('lang'); + +if (_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); +} + +export const lang = _lang; + +export const locale = await count(clientDb.i18n).then(async n => { + if (n === 0) return await setI18nContexts(_lang, version); + if ((await get('_version_', clientDb.i18n) !== version)) return await setI18nContexts(_lang, version, true); + + return await getLocale(); +}); + +export const i18n = createI18n({ + sync: false, + locale: _lang, + messages: { [_lang]: locale } +}); diff --git a/src/client/init.ts b/src/client/init.ts index 3931329aa5..96e8e90552 100644 --- a/src/client/init.ts +++ b/src/client/init.ts @@ -2,62 +2,56 @@ * Client 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 VueI18n from 'vue-i18n'; +import '@/style.scss'; + +import { createApp } from 'vue'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; -import { AiScript } from '@syuilo/aiscript'; -import { deserialize } from '@syuilo/aiscript/built/serializer'; -import VueHotkey from './scripts/hotkey'; -import App from './app.vue'; -import Deck from './deck.vue'; -import MiOS from './mios'; -import { version, langs, instanceName, getLocale, deckmode } from './config'; -import PostFormDialog from './components/post-form-dialog.vue'; -import Dialog from './components/dialog.vue'; -import Menu from './components/menu.vue'; -import Form from './components/form-window.vue'; +import Root from './root.vue'; +import widgets from './widgets'; +import directives from './directives'; +import components from '@/components'; +import { version, apiUrl } from '@/config'; +import { store } from './store'; import { router } from './router'; -import { applyTheme, lightTheme } from './scripts/theme'; -import { isDeviceDarkmode } from './scripts/is-device-darkmode'; -import createStore from './store'; -import { clientDb, get, count } from './db'; -import { setI18nContexts } from './scripts/set-i18n-contexts'; -import { createPluginEnv } from './scripts/aiscript/api'; +import { applyTheme } from '@/scripts/theme'; +import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; +import { i18n, lang } from './i18n'; +import { stream, sound, isMobile, dialog } from '@/os'; -Vue.use(Vuex); -Vue.use(VueHotkey); -Vue.use(VueMeta); -Vue.use(PortalVue); -Vue.use(VAnimateCss); -Vue.use(VueI18n); -Vue.component('fa', FontAwesomeIcon); +console.info(`Misskey v${version}`); -require('./directives'); -require('./components'); -require('./widgets'); -require('./filters'); +if (_DEV_) { + console.warn('Development mode!!!'); -Vue.mixin({ - methods: { - destroyDom() { - this.$destroy(); + window.addEventListener('error', event => { + console.error(event); + /* + dialog({ + type: 'error', + title: 'DEV: Unhandled error', + text: event.message + }); + */ + }); - if (this.$el.parentNode) { - this.$el.parentNode.removeChild(this.$el); - } - } - } -}); + window.addEventListener('unhandledrejection', event => { + console.error(event); + /* + dialog({ + type: 'error', + title: 'DEV: Unhandled promise rejection', + text: event.reason + }); + */ + }); +} -console.info(`Misskey v${version}`); +// タッチデバイスでCSSの:hoverを機能させる +document.addEventListener('touchend', () => {}, { passive: true }); if (localStorage.getItem('theme') == null) { - applyTheme(lightTheme); + applyTheme(require('@/themes/white.json5')); } //#region SEE: https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ @@ -70,29 +64,6 @@ window.addEventListener('resize', () => { }); //#endregion -//#region Detect the user language -let lang = localStorage.getItem('lang'); - -if (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(); -const isMobile = /mobile|iphone|ipad|android/.test(ua); - // Get the <head> element const head = document.getElementsByTagName('head')[0]; @@ -109,10 +80,99 @@ const html = document.documentElement; html.setAttribute('lang', lang); //#endregion -// アプリ基底要素マウント -document.body.innerHTML = '<div id="app"></div>'; +//#region Fetch user +const signout = () => { + store.dispatch('logout'); + location.href = '/'; +}; + +// ユーザーをフェッチしてコールバックする +const fetchme = (token) => new Promise((done, fail) => { + // Fetch user + fetch(`${apiUrl}/i`, { + method: 'POST', + body: JSON.stringify({ + i: token + }) + }) + .then(res => { + // When failed to authenticate user + if (res.status !== 200 && res.status < 500) { + return signout(); + } + + // Parse response + res.json().then(i => { + i.token = token; + done(i); + }); + }) + .catch(fail); +}); + +// キャッシュがあったとき +if (store.state.i != null) { + // TODO: i.token が null になるケースってどんな時だっけ? + if (store.state.i.token == null) { + signout(); + } + + // 後から新鮮なデータをフェッチ + fetchme(store.state.i.token).then(freshData => { + store.dispatch('mergeMe', freshData); + }); +} else { + // Get token from localStorage + let i = localStorage.getItem('i'); + + // 連携ログインの場合用にCookieを参照する + if (i == null || i === 'null') { + i = (document.cookie.match(/igi=(\w+)/) || [null, null])[1]; + } + + if (i != null && i !== 'null') { + try { + document.body.innerHTML = '<div>Please wait...</div>'; + const me = await fetchme(i); + await store.dispatch('login', me); + location.reload(); + } catch (e) { + // Render the error screen + // TODO: ちゃんとしたコンポーネントをレンダリングする(v10とかのトラブルシューティングゲーム付きのやつみたいな) + document.body.innerHTML = '<div id="err">Oops!</div>'; + } + } +} +//#endregion + +store.dispatch('instance/fetch').then(() => { + // Init service worker + //if (this.store.state.instance.meta.swPublickey) this.registerSw(this.store.state.instance.meta.swPublickey); +}); + +stream.init(store.state.i); + +const app = createApp(Root); + +if (_DEV_) { + app.config.performance = true; +} + +app.use(store); +app.use(router); +app.use(i18n); +// eslint-disable-next-line vue/component-definition-name-casing +app.component('Fa', FontAwesomeIcon); -const store = createStore(); +widgets(app); +directives(app); +components(app); + +await router.isReady(); + +//document.body.innerHTML = '<div id="app"></div>'; + +app.mount('body'); // 他のタブと永続化されたstateを同期 window.addEventListener('storage', e => { @@ -126,281 +186,176 @@ window.addEventListener('storage', e => { } }, false); -const os = new MiOS(store); - -os.init(async () => { - //#region Fetch locale data - const i18n = new VueI18n(); - - await count(clientDb.i18n).then(async n => { - if (n === 0) return setI18nContexts(lang, version, i18n); - if ((await get('_version_', clientDb.i18n) !== version)) return setI18nContexts(lang, version, i18n, true); - - i18n.locale = lang; - i18n.setLocaleMessage(lang, await getLocale()); +store.watch(state => state.device.darkMode, darkMode => { + import('@/scripts/theme').then(({ builtinThemes }) => { + const themes = builtinThemes.concat(store.state.device.themes); + applyTheme(themes.find(x => x.id === (darkMode ? store.state.device.darkTheme : store.state.device.lightTheme))); }); - //#endregion - - const app = new Vue({ - store: store, - i18n, - metaInfo: { - title: null, - titleTemplate: title => title ? `${title} | ${(instanceName || 'Misskey')}` : (instanceName || 'Misskey') - }, - data() { - return { - stream: os.stream, - isMobile: isMobile, - i18n // TODO: 消せないか考える SEE: https://github.com/syuilo/misskey/pull/6396#discussion_r429511030 - }; - }, - // TODO: ここらへんのメソッド全部Vuexに移したい - methods: { - api: (endpoint: string, data: { [x: string]: any } = {}, token?) => store.dispatch('api', { endpoint, data, token }), - 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; - }, - form(title, form) { - const vm = this.new(Form, { title, form }); - return new Promise((res) => { - vm.$once('ok', result => res({ canceled: false, result })); - vm.$once('cancel', () => res({ canceled: true })); - }); - }, - post(opts, cb) { - if (!this.$store.getters.isSignedIn) return; - const vm = this.new(PostFormDialog, opts); - if (cb) vm.$once('closed', cb); - (vm as any).focus(); - }, - sound(type: string) { - if (this.$store.state.device.sfxVolume === 0) return; - const sound = this.$store.state.device['sfx' + type.substr(0, 1).toUpperCase() + type.substr(1)]; - if (sound == null) return; - const audio = new Audio(`/assets/sounds/${sound}.mp3`); - audio.volume = this.$store.state.device.sfxVolume; - audio.play(); - } - }, - router: router, - render: createEl => createEl(deckmode ? Deck : App) - }); - - // マウント - app.$mount('#app'); +}); - store.watch(state => state.device.darkMode, darkMode => { - import('./scripts/theme').then(({ builtinThemes }) => { - const themes = builtinThemes.concat(store.state.device.themes); - applyTheme(themes.find(x => x.id === (darkMode ? store.state.device.darkTheme : store.state.device.lightTheme))); - }); - }); +//#region Sync dark mode +if (store.state.device.syncDeviceDarkMode) { + store.commit('device/set', { key: 'darkMode', value: isDeviceDarkmode() }); +} - //#region Sync dark mode +window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => { if (store.state.device.syncDeviceDarkMode) { - store.commit('device/set', { key: 'darkMode', value: isDeviceDarkmode() }); + store.commit('device/set', { key: 'darkMode', value: mql.matches }); } +}); +//#endregion - window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => { - if (store.state.device.syncDeviceDarkMode) { - store.commit('device/set', { key: 'darkMode', value: mql.matches }); - } - }); - //#endregion - - store.watch(state => state.device.useBlurEffectForModal, v => { - document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none'); - }, { immediate: true }); +store.watch(state => state.device.useBlurEffectForModal, v => { + document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none'); +}, { immediate: true }); - let reloadDialogShowing = false; - os.stream.on('_disconnected_', async () => { - if (store.state.device.serverDisconnectedBehavior === 'reload') { +let reloadDialogShowing = false; +stream.on('_disconnected_', async () => { + if (store.state.device.serverDisconnectedBehavior === 'reload') { + location.reload(); + } else if (store.state.device.serverDisconnectedBehavior === 'dialog') { + if (reloadDialogShowing) return; + reloadDialogShowing = true; + const { canceled } = await dialog({ + type: 'warning', + title: i18n.global.t('disconnectedFromServer'), + text: i18n.global.t('reloadConfirm'), + showCancelButton: true + }); + reloadDialogShowing = false; + if (!canceled) { location.reload(); - } else if (store.state.device.serverDisconnectedBehavior === 'dialog') { - if (reloadDialogShowing) return; - reloadDialogShowing = true; - const { canceled } = await app.dialog({ - type: 'warning', - title: app.$t('disconnectedFromServer'), - text: app.$t('reloadConfirm'), - showCancelButton: true - }); - reloadDialogShowing = false; - if (!canceled) { - location.reload(); - } } - }); - - os.stream.on('emojiAdded', data => { - // TODO - //store.commit('instance/set', ); - }); - - for (const plugin of store.state.deviceUser.plugins.filter(p => p.active)) { - console.info('Plugin installed:', plugin.name, 'v' + plugin.version); + } +}); - const aiscript = new AiScript(createPluginEnv(app, { - plugin: plugin, - storageKey: 'plugins:' + plugin.id - }), { - in: (q) => { - return new Promise(ok => { - app.dialog({ - title: q, - input: {} - }).then(({ canceled, result: a }) => { - ok(a); - }); - }); - }, - out: (value) => { - console.log(value); - }, - log: (type, params) => { - }, - }); +stream.on('emojiAdded', data => { + // TODO + //store.commit('instance/set', ); +}); - store.commit('initPlugin', { plugin, aiscript }); +for (const plugin of store.state.deviceUser.plugins.filter(p => p.active)) { + import('./plugin').then(({ install }) => { + install(plugin); + }); +} - aiscript.exec(deserialize(plugin.ast)); +if (store.getters.isSignedIn) { + if ('Notification' in window) { + // 許可を得ていなかったらリクエスト + if (Notification.permission === 'default') { + Notification.requestPermission(); + } } - if (store.getters.isSignedIn) { - if ('Notification' in window) { - // 許可を得ていなかったらリクエスト - if (Notification.permission === 'default') { - Notification.requestPermission(); - } - } + const main = stream.useSharedConnection('main'); - const main = os.stream.useSharedConnection('main'); + // 自分の情報が更新されたとき + main.on('meUpdated', i => { + store.dispatch('mergeMe', i); + }); - // 自分の情報が更新されたとき - main.on('meUpdated', i => { - store.dispatch('mergeMe', i); + main.on('readAllNotifications', () => { + store.dispatch('mergeMe', { + hasUnreadNotification: false }); + }); - main.on('readAllNotifications', () => { - store.dispatch('mergeMe', { - hasUnreadNotification: false - }); + main.on('unreadNotification', () => { + store.dispatch('mergeMe', { + hasUnreadNotification: true }); + }); - main.on('unreadNotification', () => { - store.dispatch('mergeMe', { - hasUnreadNotification: true - }); + main.on('unreadMention', () => { + store.dispatch('mergeMe', { + hasUnreadMentions: true }); + }); - main.on('unreadMention', () => { - store.dispatch('mergeMe', { - hasUnreadMentions: true - }); + main.on('readAllUnreadMentions', () => { + store.dispatch('mergeMe', { + hasUnreadMentions: false }); + }); - main.on('readAllUnreadMentions', () => { - store.dispatch('mergeMe', { - hasUnreadMentions: false - }); + main.on('unreadSpecifiedNote', () => { + store.dispatch('mergeMe', { + hasUnreadSpecifiedNotes: true }); + }); - main.on('unreadSpecifiedNote', () => { - store.dispatch('mergeMe', { - hasUnreadSpecifiedNotes: true - }); + main.on('readAllUnreadSpecifiedNotes', () => { + store.dispatch('mergeMe', { + hasUnreadSpecifiedNotes: false }); + }); - main.on('readAllUnreadSpecifiedNotes', () => { - store.dispatch('mergeMe', { - hasUnreadSpecifiedNotes: false - }); + main.on('readAllMessagingMessages', () => { + store.dispatch('mergeMe', { + hasUnreadMessagingMessage: false }); + }); - main.on('readAllMessagingMessages', () => { - store.dispatch('mergeMe', { - hasUnreadMessagingMessage: false - }); + main.on('unreadMessagingMessage', () => { + store.dispatch('mergeMe', { + hasUnreadMessagingMessage: true }); - main.on('unreadMessagingMessage', () => { - store.dispatch('mergeMe', { - hasUnreadMessagingMessage: true - }); + sound('chatBg'); + }); - app.sound('chatBg'); + main.on('readAllAntennas', () => { + store.dispatch('mergeMe', { + hasUnreadAntenna: false }); + }); - main.on('readAllAntennas', () => { - store.dispatch('mergeMe', { - hasUnreadAntenna: false - }); + main.on('unreadAntenna', () => { + store.dispatch('mergeMe', { + hasUnreadAntenna: true }); - main.on('unreadAntenna', () => { - store.dispatch('mergeMe', { - hasUnreadAntenna: true - }); + sound('antenna'); + }); - app.sound('antenna'); + main.on('readAllAnnouncements', () => { + store.dispatch('mergeMe', { + hasUnreadAnnouncement: false }); + }); - main.on('readAllChannels', () => { - store.dispatch('mergeMe', { - hasUnreadChannel: false - }); + main.on('readAllChannels', () => { + store.dispatch('mergeMe', { + hasUnreadChannel: false }); + }); - main.on('unreadChannel', () => { - store.dispatch('mergeMe', { - hasUnreadChannel: true - }); - - app.sound('channel'); + main.on('unreadChannel', () => { + store.dispatch('mergeMe', { + hasUnreadChannel: true }); - main.on('readAllAnnouncements', () => { - store.dispatch('mergeMe', { - hasUnreadAnnouncement: false - }); - }); + sound('channel'); + }); - main.on('clientSettingUpdated', x => { - store.commit('settings/set', { - key: x.key, - value: x.value - }); + main.on('readAllAnnouncements', () => { + store.dispatch('mergeMe', { + hasUnreadAnnouncement: false }); + }); - // トークンが再生成されたとき - // このままではMisskeyが利用できないので強制的にサインアウトさせる - main.on('myTokenRegenerated', () => { - os.signout(); + main.on('clientSettingUpdated', x => { + store.commit('settings/set', { + key: x.key, + value: x.value }); - } -}); + }); + + // トークンが再生成されたとき + // このままではMisskeyが利用できないので強制的にサインアウトさせる + main.on('myTokenRegenerated', () => { + signout(); + }); +} + diff --git a/src/client/mios.ts b/src/client/mios.ts deleted file mode 100644 index efeb630d7e..0000000000 --- a/src/client/mios.ts +++ /dev/null @@ -1,236 +0,0 @@ -// TODO: このファイル消したい - -import autobind from 'autobind-decorator'; -import { EventEmitter } from 'eventemitter3'; - -import { apiUrl, version } from './config'; -import Progress from './scripts/loading'; - -import Stream from './scripts/stream'; -import store from './store'; - -/** - * Misskey Operating System - */ -export default class MiOS extends EventEmitter { - public store: ReturnType<typeof store>; - - /** - * A connection manager of home stream - */ - public stream: Stream; - - /** - * A registration of service worker - */ - private swRegistration: ServiceWorkerRegistration = null; - - constructor(vuex: MiOS['store']) { - super(); - this.store = vuex; - } - - @autobind - public signout() { - this.store.dispatch('logout'); - location.href = '/'; - } - - /** - * Initialize MiOS (boot) - * @param callback A function that call when initialized - */ - @autobind - public async init(callback) { - const finish = () => { - callback(); - - this.store.dispatch('instance/fetch').then(() => { - // Init service worker - if (this.store.state.instance.meta.swPublickey) this.registerSw(this.store.state.instance.meta.swPublickey); - }); - }; - - // ユーザーをフェッチしてコールバックする - const fetchme = (token, cb) => { - let me = null; - - // Return when not signed in - if (token == null || token === 'null') { - return done(); - } - - // Fetch user - fetch(`${apiUrl}/i`, { - method: 'POST', - body: JSON.stringify({ - i: token - }) - }) - // When success - .then(res => { - // When failed to authenticate user - if (res.status !== 200 && res.status < 500) { - return this.signout(); - } - - // Parse response - res.json().then(i => { - me = i; - me.token = token; - done(); - }); - }) - // When failure - .catch(() => { - // Render the error screen - document.body.innerHTML = '<div id="err">Oops!</div>'; - - Progress.done(); - }); - - function done() { - if (cb) cb(me); - } - }; - - // フェッチが完了したとき - const fetched = () => { - this.emit('signedin'); - - this.initStream(); - - // Finish init - finish(); - }; - - // キャッシュがあったとき - if (this.store.state.i != null) { - if (this.store.state.i.token == null) { - this.signout(); - return; - } - - // とりあえずキャッシュされたデータでお茶を濁して(?)おいて、 - fetched(); - - // 後から新鮮なデータをフェッチ - fetchme(this.store.state.i.token, freshData => { - this.store.dispatch('mergeMe', freshData); - }); - } else { - // Get token from localStorage - let i = localStorage.getItem('i'); - - // 連携ログインの場合用にCookieを参照する - if (i == null || i === 'null') { - i = (document.cookie.match(/igi=(\w+)/) || [null, null])[1]; - } - - fetchme(i, me => { - if (me) { - this.store.dispatch('login', me); - fetched(); - } else { - this.initStream(); - - // Finish init - finish(); - } - }); - } - } - - @autobind - private initStream() { - this.stream = new Stream(this); - } - - /** - * Register service worker - */ - @autobind - private registerSw(swPublickey: string) { - // Check whether service worker and push manager supported - const isSwSupported = - ('serviceWorker' in navigator) && ('PushManager' in window); - - // Reject when browser not service worker supported - if (!isSwSupported) return; - - // Reject when not signed in to Misskey - if (!this.store.getters.isSignedIn) return; - - // When service worker activated - navigator.serviceWorker.ready.then(registration => { - this.swRegistration = registration; - - // Options of pushManager.subscribe - // SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters - const opts = { - // A boolean indicating that the returned push subscription - // will only be used for messages whose effect is made visible to the user. - userVisibleOnly: true, - - // A public key your push server will use to send - // messages to client apps via a push server. - applicationServerKey: urlBase64ToUint8Array(swPublickey) - }; - - // Subscribe push notification - this.swRegistration.pushManager.subscribe(opts).then(subscription => { - function encode(buffer: ArrayBuffer) { - return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))); - } - - // Register - this.store.dispatch('api', { - endpoint: 'sw/register', - data: { - endpoint: subscription.endpoint, - auth: encode(subscription.getKey('auth')), - publickey: encode(subscription.getKey('p256dh')) - } - }); - }) - // When subscribe failed - .catch(async (err: Error) => { - // 通知が許可されていなかったとき - if (err.name === 'NotAllowedError') { - return; - } - - // 違うapplicationServerKey (または gcm_sender_id)のサブスクリプションが - // 既に存在していることが原因でエラーになった可能性があるので、 - // そのサブスクリプションを解除しておく - const subscription = await this.swRegistration.pushManager.getSubscription(); - if (subscription) subscription.unsubscribe(); - }); - }); - - // The path of service worker script - const sw = `/sw.${version}.js`; - - // Register service worker - navigator.serviceWorker.register(sw); - } -} - -/** - * Convert the URL safe base64 string to a Uint8Array - * @param base64String base64 string - */ -function urlBase64ToUint8Array(base64String: string): Uint8Array { - const padding = '='.repeat((4 - base64String.length % 4) % 4); - const base64 = (base64String + padding) - .replace(/-/g, '+') - .replace(/_/g, '/'); - - const rawData = window.atob(base64); - const outputArray = new Uint8Array(rawData.length); - - for (let i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i); - } - return outputArray; -} diff --git a/src/client/os.ts b/src/client/os.ts new file mode 100644 index 0000000000..03bae65539 --- /dev/null +++ b/src/client/os.ts @@ -0,0 +1,364 @@ +import { Component, defineAsyncComponent, markRaw, reactive, Ref, ref } from 'vue'; +import { EventEmitter } from 'eventemitter3'; +import Stream from '@/scripts/stream'; +import { store } from '@/store'; +import { apiUrl } from '@/config'; +import MkPostFormDialog from '@/components/post-form-dialog.vue'; + +const ua = navigator.userAgent.toLowerCase(); +export const isMobile = /mobile|iphone|ipad|android/.test(ua); + +export const stream = new Stream(); + +export const pendingApiRequestsCount = ref(0); + +export const windows = new Map(); + +export function api(endpoint: string, data: Record<string, any> = {}, token?: string | null | undefined) { + pendingApiRequestsCount.value++; + + if (_DEV_) { + performance.mark(_PERF_PREFIX_ + 'api:begin'); + } + + const onFinally = () => { + pendingApiRequestsCount.value--; + + if (_DEV_) { + performance.mark(_PERF_PREFIX_ + 'api:end'); + + performance.measure(_PERF_PREFIX_ + 'api', + _PERF_PREFIX_ + 'api:begin', + _PERF_PREFIX_ + 'api:end'); + } + }; + + const promise = new Promise((resolve, reject) => { + // Append a credential + if (store.getters.isSignedIn) (data as any).i = store.state.i.token; + if (token !== undefined) (data as any).i = token; + + // Send request + fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + credentials: 'omit', + cache: 'no-cache' + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }).catch(reject); + }); + + promise.then(onFinally, onFinally); + + return promise; +} + +export function apiWithDialog(endpoint: string, data: Record<string, any> = {}, token?: string | null | undefined, onSuccess?: (res: any) => void, onFailure?: (e: Error) => void) { + const showing = ref(true); + const state = ref('waiting'); + + const promise = api(endpoint, data, token); + promise.then(res => { + if (onSuccess) { + showing.value = false; + onSuccess(res); + } else { + state.value = 'success'; + setTimeout(() => { + showing.value = false; + }, 1000); + } + }).catch(e => { + showing.value = false; + if (onFailure) { + onFailure(e); + } else { + dialog({ + type: 'error', + text: e + }); + } + }); + + popup(defineAsyncComponent(() => import('@/components/icon-dialog.vue')), { + type: state, + showing: showing + }, {}, 'closed'); + + return promise; +} + +function isModule(x: any): x is typeof import('*.vue') { + return x.default != null; +} + +export const popups = ref([]) as Ref<{ + id: any; + component: any; + props: Record<string, any>; +}[]>; + +export function popup(component: Component | typeof import('*.vue'), props: Record<string, any>, events = {}, disposeEvent?: string) { + if (isModule(component)) component = component.default; + markRaw(component); + + const id = Math.random().toString(); // TODO: uuidとか使う + const dispose = () => { + if (_DEV_) console.log('os:popup close', id, component, props, events); + // このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ? + setTimeout(() => { + popups.value = popups.value.filter(popup => popup.id !== id); + }, 0); + }; + const state = { + component, + props, + events: disposeEvent ? { + ...events, + [disposeEvent]: dispose + } : events, + id, + }; + + if (_DEV_) console.log('os:popup open', id, component, props, events); + popups.value.push(state); + + return { + dispose, + }; +} + +export function pageWindow(url: string, component: Component | typeof import('*.vue'), props: Record<string, any>) { + popup(defineAsyncComponent(() => import('@/components/page-window.vue')), { + initialUrl: url, + initialComponent: markRaw(component), + initialProps: props, + }, {}, 'closed'); +} + +export function dialog(props: Record<string, any>) { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/dialog.vue')), props, { + done: result => { + resolve(result ? result : { canceled: true }); + }, + }, 'closed'); + }); +} + +export function success() { + return new Promise((resolve, reject) => { + const showing = ref(true); + setTimeout(() => { + showing.value = false; + }, 1000); + popup(defineAsyncComponent(() => import('@/components/icon-dialog.vue')), { + type: 'success', + showing: showing + }, { + done: () => resolve(), + }, 'closed'); + }); +} + +export function waiting() { + return new Promise((resolve, reject) => { + const showing = ref(true); + popup(defineAsyncComponent(() => import('@/components/icon-dialog.vue')), { + type: 'waiting', + showing: showing + }, { + done: () => resolve(), + }, 'closed'); + }); +} + +export function form(title, form) { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/form-dialog.vue')), { title, form }, { + done: result => { + resolve(result); + }, + }, 'closed'); + }); +} + +export async function selectUser() { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/user-select-dialog.vue')), {}, { + ok: user => { + resolve(user); + }, + }, 'closed'); + }); +} + +export async function selectDriveFile(multiple: boolean) { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/drive-window.vue')), { + type: 'file', + multiple + }, { + done: files => { + if (files) { + resolve(multiple ? files : files[0]); + } + }, + }, 'closed'); + }); +} + +export async function selectDriveFolder(multiple: boolean) { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/drive-window.vue')), { + type: 'folder', + multiple + }, { + done: folders => { + if (folders) { + resolve(multiple ? folders : folders[0]); + } + }, + }, 'closed'); + }); +} + +export async function pickEmoji(src?: HTMLElement) { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/emoji-picker.vue')), { + src + }, { + done: emoji => { + resolve(emoji); + }, + }, 'closed'); + }); +} + +export function modalMenu(items: any[], src?: HTMLElement, options?: { align?: string; viaKeyboard?: boolean }) { + return new Promise((resolve, reject) => { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/ui/modal-menu.vue')), { + items, + src, + align: options?.align, + viaKeyboard: options?.viaKeyboard + }, { + closed: () => { + resolve(); + dispose(); + }, + }); + }); +} + +export function contextMenu(items: any[], ev: MouseEvent) { + ev.preventDefault(); + return new Promise((resolve, reject) => { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/ui/context-menu.vue')), { + items, + ev, + }, { + closed: () => { + resolve(); + dispose(); + }, + }); + }); +} + +export function post(props: Record<string, any>) { + return new Promise((resolve, reject) => { + // NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない + const { dispose } = popup(MkPostFormDialog, props, { + closed: () => { + resolve(); + dispose(); + }, + }); + }); +} + +export function sound(type: string) { + if (store.state.device.sfxVolume === 0) return; + const sound = store.state.device['sfx' + type.substr(0, 1).toUpperCase() + type.substr(1)]; + if (sound == null) return; + const audio = new Audio(`/assets/sounds/${sound}.mp3`); + audio.volume = store.state.device.sfxVolume; + audio.play(); +} + +export const deckGlobalEvents = new EventEmitter(); + +export const uploads = ref([]); + +export function upload(file: File, folder?: any, name?: string) { + if (folder && typeof folder == 'object') folder = folder.id; + + return new Promise((resolve, reject) => { + const id = Math.random(); + + const reader = new FileReader(); + reader.onload = (e) => { + const ctx = reactive({ + id: id, + name: name || file.name || 'untitled', + progressMax: undefined, + progressValue: undefined, + img: window.URL.createObjectURL(file) + }); + + uploads.value.push(ctx); + + const data = new FormData(); + data.append('i', 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); + + resolve(driveFile); + + uploads.value = uploads.value.filter(x => x.id != id); + }; + + xhr.upload.onprogress = e => { + if (e.lengthComputable) { + ctx.progressMax = e.total; + ctx.progressValue = e.loaded; + } + }; + + xhr.send(data); + }; + reader.readAsArrayBuffer(file); + }); +} + +/* +export function checkExistence(fileData: ArrayBuffer): Promise<any> { + return new Promise((resolve, reject) => { + const data = new FormData(); + data.append('md5', getMD5(fileData)); + + os.api('drive/files/find-by-hash', { + md5: getMD5(fileData) + }).then(resp => { + resolve(resp.length > 0 ? resp[0] : null); + }); + }); +}*/ diff --git a/src/client/pages/_error_.vue b/src/client/pages/_error_.vue new file mode 100644 index 0000000000..c2497c17b3 --- /dev/null +++ b/src/client/pages/_error_.vue @@ -0,0 +1,55 @@ +<template> +<transition :name="$store.state.device.animation ? 'zoom' : ''" appear> + <div class="_section"> + <div class="mjndxjch _content"> + <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> + <p><Fa :icon="faExclamationTriangle"/> {{ $t('pageLoadError') }}</p> + <p>{{ $t('pageLoadErrorDescription') }}</p> + </div> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '@/components/ui/button.vue'; + +export default defineComponent({ + components: { + MkButton, + }, + data() { + return { + INFO: { + header: [{ + title: this.$t('error'), + icon: faExclamationTriangle + }] + }, + faExclamationTriangle + }; + }, +}); +</script> + +<style lang="scss" scoped> +.mjndxjch { + text-align: center; + + > p { + margin: 0 0 8px 0; + } + + > .button { + margin: 0 auto; + } + + > img { + vertical-align: bottom; + height: 128px; + margin-bottom: 16px; + border-radius: 16px; + } +} +</style> diff --git a/src/client/pages/_loading_.vue b/src/client/pages/_loading_.vue new file mode 100644 index 0000000000..05c6af1cd7 --- /dev/null +++ b/src/client/pages/_loading_.vue @@ -0,0 +1,10 @@ +<template> +<MkLoading/> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({}); +</script> diff --git a/src/client/pages/about-misskey.vue b/src/client/pages/about-misskey.vue index 2c4a257b15..721e736902 100644 --- a/src/client/pages/about-misskey.vue +++ b/src/client/pages/about-misskey.vue @@ -1,83 +1,97 @@ <template> <div class="znqjceqz"> - <portal to="title">{{ $t('aboutMisskey') }}</portal> - - <section class="_card"> - <div class="_title">{{ $t('aboutMisskey') }}</div> + <section class="_section"> <div class="_content" style="text-align: center;"> <img src="/assets/icons/512.png" alt="" style="display: block; width: 100px; margin: 0 auto; border-radius: 16px;"/> <div style="margin-top: 0.75em;">Misskey</div> <div style="opacity: 0.5;">v{{ version }}</div> </div> + </section> + <section class="_section"> <div class="_content"> - <div style="margin-bottom: 1em;">{{ $t('aboutMisskeyText') }}</div> - <div>🛠️ {{ $t('misskeyMembers') }}</div> - <ul class="members"> - <li><mk-link url="https://github.com/syuilo" class="at">@syuilo</mk-link></li> - <li><mk-link url="https://github.com/AyaMorisawa" class="at">@AyaMorisawa</mk-link></li> - <li><mk-link url="https://github.com/mei23" class="at">@mei23</mk-link></li> - <li><mk-link url="https://github.com/acid-chicken" class="at">@acid-chicken</mk-link></li> - <li><mk-link url="https://github.com/tamaina" class="at">@tamaina</mk-link></li> - <li><mk-link url="https://github.com/rinsuki" class="at">@rinsuki</mk-link></li> - <li><mk-link url="https://github.com/Xeltica" class="at">@Xeltica</mk-link></li> - <li><mk-link url="https://github.com/u1-liquid" class="at">@u1-liquid</mk-link></li> - </ul> - <div style="margin-top: 1em;">📦 {{ $t('misskeySource') }}</div> - <mk-url url="https://github.com/syuilo/misskey"/> + <div style="text-align: center;">{{ $t('aboutMisskeyText') }}</div> + </div> + </section> + <section class="_section"> + <div class="_content" style="text-align: center;"> + <div>📦 {{ $t('misskeySource') }}</div> + <MkUrl url="https://github.com/syuilo/misskey"/> <div style="margin-top: 1em;">🌏 {{ $t('misskeyTranslation') }}</div> - <mk-url url="https://crowdin.com/project/misskey"/> + <MkUrl url="https://crowdin.com/project/misskey"/> <div style="margin-top: 1em;">💴 {{ $t('misskeyDonate') }}</div> - <mk-url url="https://www.patreon.com/syuilo"/> + <MkUrl url="https://www.patreon.com/syuilo"/> </div> - <div class="_content"> - <span><mfm text="<motion>❤</motion>"/> {{ $t('patrons') }}</span> - <ul> - <li>Gargron</li> - <li>Satsuki Yanagi</li> - <li>noellabo</li> - <li>naga_rus</li> - <li>Melilot</li> - <li>AureoleArk</li> - <li>Peter G.</li> - <li>motcha</li> - <li>Atsuko Tominaga</li> - <li>dansup</li> - <li>Nokotaro Takeda</li> - <li>YUKIMOCHI</li> - <li>nanami kan</li> - <li>Hekovic</li> - <li>wara</li> - <li>Takashi Shibuya</li> - <li>Noizeman</li> - <li>mydarkstar</li> - <li>nenohi</li> - <li>Eduardo Quiros</li> + </section> + <section class="_section"> + <div class="_content" style="text-align: center;"> + <div>🛠️ {{ $t('misskeyMembers') }}</div> + <ul class="members" style="list-style: none; padding: 0; margin: 1em 0 0 0;"> + <li><MkLink url="https://github.com/syuilo" class="at">@syuilo</MkLink></li> + <li><MkLink url="https://github.com/AyaMorisawa" class="at">@AyaMorisawa</MkLink></li> + <li><MkLink url="https://github.com/mei23" class="at">@mei23</MkLink></li> + <li><MkLink url="https://github.com/acid-chicken" class="at">@acid-chicken</MkLink></li> + <li><MkLink url="https://github.com/tamaina" class="at">@tamaina</MkLink></li> + <li><MkLink url="https://github.com/rinsuki" class="at">@rinsuki</MkLink></li> + <li><MkLink url="https://github.com/Xeltica" class="at">@Xeltica</MkLink></li> + <li><MkLink url="https://github.com/u1-liquid" class="at">@u1-liquid</MkLink></li> </ul> - <span>{{ $t('morePatrons') }}</span> + </div> + </section> + <section class="_section"> + <div class="_content"> + <div class="_card"> + <div class="_title"><Mfm text="<motion>❤</motion>"/> {{ $t('patrons') }}</div> + <div class="_content"> + <ul style="margin: 0;"> + <li>Gargron</li> + <li>Satsuki Yanagi</li> + <li>noellabo</li> + <li>naga_rus</li> + <li>Melilot</li> + <li>AureoleArk</li> + <li>Peter G.</li> + <li>motcha</li> + <li>Atsuko Tominaga</li> + <li>dansup</li> + <li>Nokotaro Takeda</li> + <li>YUKIMOCHI</li> + <li>nanami kan</li> + <li>Hekovic</li> + <li>wara</li> + <li>Takashi Shibuya</li> + <li>Noizeman</li> + <li>mydarkstar</li> + <li>nenohi</li> + <li>Eduardo Quiros</li> + </ul> + </div> + <div class="_footer">{{ $t('morePatrons') }}</div> + </div> </div> </section> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'; -import { version } from '../config'; -import MkLink from '../components/link.vue'; +import { version } from '@/config'; +import MkLink from '@/components/link.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkLink }, - metaInfo() { - return { - title: this.$t('aboutMisskey') as string - }; - }, - data() { return { + INFO: { + header: [{ + title: this.$t('aboutMisskey'), + icon: null + }] + }, version, faInfoCircle } diff --git a/src/client/pages/about.vue b/src/client/pages/about.vue index 25fb0ca13e..042122ceec 100644 --- a/src/client/pages/about.vue +++ b/src/client/pages/about.vue @@ -1,10 +1,7 @@ <template> <div class="mmnnbwxb"> - <portal to="icon"><fa :icon="faInfoCircle"/></portal> - <portal to="title">{{ $t('about') }}</portal> - - <section class="_card info" v-if="meta"> - <div class="_title"><fa :icon="faInfoCircle"/> {{ $t('instanceInfo') }}</div> + <section class="_section info" v-if="meta"> + <div class="_title"><Fa :icon="faInfoCircle"/> {{ $t('instanceInfo') }}</div> <div class="_content" v-if="meta.description"> <div v-html="meta.description"></div> </div> @@ -17,29 +14,34 @@ </div> </section> - <mk-instance-stats style="margin-top: var(--margin);"/> + <div class="_section"> + <div class="_content"> + <MkInstanceStats/> + </div> + </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'; -import { version } from '../config'; -import MkInstanceStats from '../components/instance-stats.vue'; - -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('instance') as string - }; - }, +import { version } from '@/config'; +import MkInstanceStats from '@/components/instance-stats.vue'; +import * as os from '@/os'; +export default defineComponent({ components: { MkInstanceStats }, data() { return { + INFO: { + header: [{ + title: this.$t('about'), + icon: faInfoCircle + }] + }, version, serverInfo: null, faInfoCircle diff --git a/src/client/pages/announcements.vue b/src/client/pages/announcements.vue index 0047599749..9815f2df9e 100644 --- a/src/client/pages/announcements.vue +++ b/src/client/pages/announcements.vue @@ -1,36 +1,28 @@ <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"> +<div class="_section"> + <MkPagination :pagination="pagination" #default="{items}" class="ruryvtyk _content" ref="list"> <section class="_card announcement" v-for="(announcement, i) in items" :key="announcement.id"> <div class="_title"><span v-if="$store.getters.isSignedIn && !announcement.isRead">🆕 </span>{{ announcement.title }}</div> <div class="_content"> - <mfm :text="announcement.text"/> + <Mfm :text="announcement.text"/> <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> </div> <div class="_footer" v-if="$store.getters.isSignedIn && !announcement.isRead"> - <mk-button @click="read(items, announcement, i)" primary><fa :icon="faCheck"/> {{ $t('gotIt') }}</mk-button> + <MkButton @click="read(items, announcement, i)" primary><Fa :icon="faCheck"/> {{ $t('gotIt') }}</MkButton> </div> </section> - </mk-pagination> + </MkPagination> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faCheck, faBroadcastTower } 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('announcements') as string - }; - }, +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +export default defineComponent({ components: { MkPagination, MkButton @@ -38,22 +30,28 @@ export default Vue.extend({ data() { return { + INFO: { + header: [{ + title: this.$t('announcements'), + icon: faBroadcastTower + }] + }, pagination: { endpoint: 'announcements', limit: 10, }, - faCheck, faBroadcastTower + faCheck, }; }, methods: { // TODO: これは実質的に親コンポーネントから子コンポーネントのプロパティを変更してるのでなんとかしたい read(items, announcement, i) { - Vue.set(items, i, { + items[i] = { ...announcement, isRead: true, - }); - this.$root.api('i/read-announcement', { announcementId: announcement.id }); + }; + os.api('i/read-announcement', { announcementId: announcement.id }); }, } }); diff --git a/src/client/pages/apps.vue b/src/client/pages/apps.vue index 445bba34c8..790fd80961 100644 --- a/src/client/pages/apps.vue +++ b/src/client/pages/apps.vue @@ -1,9 +1,6 @@ <template> <div> - <portal to="icon"><fa :icon="faPlug"/></portal> - <portal to="title">{{ $t('installedApps') }}</portal> - - <mk-pagination :pagination="pagination" class="bfomjevm" ref="list"> + <MkPagination :pagination="pagination" class="bfomjevm" ref="list"> <template #empty> <div class="_fullinfo"> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> @@ -18,14 +15,14 @@ <div class="description">{{ token.description }}</div> <div class="_keyValue"> <div>{{ $t('installedDate') }}:</div> - <div><mk-time :time="token.createdAt"/></div> + <div><MkTime :time="token.createdAt"/></div> </div> <div class="_keyValue"> <div>{{ $t('lastUsedDate') }}:</div> - <div><mk-time :time="token.lastUsedAt"/></div> + <div><MkTime :time="token.lastUsedAt"/></div> </div> <div class="actions"> - <button class="_button" @click="revoke(token)"><fa :icon="faTrashAlt"/></button> + <button class="_button" @click="revoke(token)"><Fa :icon="faTrashAlt"/></button> </div> <details> <summary>{{ $t('details') }}</summary> @@ -36,28 +33,29 @@ </div> </div> </template> - </mk-pagination> + </MkPagination> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faTrashAlt, faPlug } from '@fortawesome/free-solid-svg-icons'; -import MkPagination from '../components/ui/pagination.vue'; - -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('installedApps') as string - }; - }, +import MkPagination from '@/components/ui/pagination.vue'; +import * as os from '@/os'; +export default defineComponent({ components: { MkPagination }, data() { return { + INFO: { + header: [{ + title: this.$t('installedApps'), + icon: faPlug, + }], + }, pagination: { endpoint: 'i/apps', limit: 100, @@ -71,7 +69,7 @@ export default Vue.extend({ methods: { revoke(token) { - this.$root.api('i/revoke-token', { tokenId: token.id }).then(() => { + os.api('i/revoke-token', { tokenId: token.id }).then(() => { this.$refs.list.reload(); }); } diff --git a/src/client/pages/auth.form.vue b/src/client/pages/auth.form.vue index c5a9b769ac..dd5aa34e6f 100644 --- a/src/client/pages/auth.form.vue +++ b/src/client/pages/auth.form.vue @@ -1,5 +1,5 @@ <template> -<section class="_card"> +<section class="_section"> <div class="_title">{{ $t('_auth.shareAccess', { name: app.name }) }}</div> <div class="_content"> <h2>{{ app.name }}</h2> @@ -9,23 +9,22 @@ <div class="_content"> <h2>{{ $t('_auth.permissionAsk') }}</h2> <ul> - <template v-for="p in app.permission"> - <li :key="p">{{ $t(`_permissions.${p}`) }}</li> - </template> + <li v-for="p in app.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li> </ul> </div> <div class="_footer"> - <mk-button @click="cancel" inline>{{ $t('cancel') }}</mk-button> - <mk-button @click="accept" inline primary>{{ $t('accept') }}</mk-button> + <MkButton @click="cancel" inline>{{ $t('cancel') }}</MkButton> + <MkButton @click="accept" inline primary>{{ $t('accept') }}</MkButton> </div> </section> </template> <script lang="ts"> -import Vue from 'vue'; -import MkButton from '../components/ui/button.vue'; +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkButton }, @@ -42,7 +41,7 @@ export default Vue.extend({ }, methods: { cancel() { - this.$root.api('auth/deny', { + os.api('auth/deny', { token: this.session.token }).then(() => { this.$emit('denied'); @@ -50,7 +49,7 @@ export default Vue.extend({ }, accept() { - this.$root.api('auth/accept', { + os.api('auth/accept', { token: this.session.token }).then(() => { this.$emit('accepted'); diff --git a/src/client/pages/auth.vue b/src/client/pages/auth.vue index 5c40842da1..4b67658b7d 100755 --- a/src/client/pages/auth.vue +++ b/src/client/pages/auth.vue @@ -1,9 +1,9 @@ <template> -<div class="_panel" v-if="$store.getters.isSignedIn && fetching"> - <mk-loading/> +<div class="" v-if="$store.getters.isSignedIn && fetching"> + <MkLoading/> </div> <div v-else-if="$store.getters.isSignedIn"> - <x-form + <XForm class="form" ref="form" v-if="state == 'waiting'" @@ -11,29 +11,30 @@ @denied="state = 'denied'" @accepted="accepted" /> - <div class="denied _panel" v-if="state == 'denied'"> + <div class="denied" v-if="state == 'denied'"> <h1>{{ $t('_auth.denied') }}</h1> </div> - <div class="accepted _panel" v-if="state == 'accepted'"> + <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('_auth.callback') }}<mk-ellipsis/></p> + <p v-if="session.app.callbackUrl">{{ $t('_auth.callback') }}<MkEllipsis/></p> <p v-if="!session.app.callbackUrl">{{ $t('_auth.pleaseGoBack') }}</p> </div> - <div class="error _panel" v-if="state == 'fetch-session-error'"> - <p>{{ $t('error') }}</p> + <div class="error" v-if="state == 'fetch-session-error'"> + <p>{{ $t('somethingHappened') }}</p> </div> </div> <div class="signin" v-else> - <mk-signin @login="onLogin"/> + <MkSignin @login="onLogin"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import XForm from './auth.form.vue'; -import MkSignin from '../components/signin.vue'; +import MkSignin from '@/components/signin.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XForm, MkSignin, @@ -54,7 +55,7 @@ export default Vue.extend({ if (!this.$store.getters.isSignedIn) return; // Fetch session - this.$root.api('auth/session/show', { + os.api('auth/session/show', { token: this.token }).then(session => { this.session = session; @@ -62,7 +63,7 @@ export default Vue.extend({ // 既に連携していた場合 if (this.session.app.isAuthorized) { - this.$root.api('auth/accept', { + os.api('auth/accept', { token: this.session.token }).then(() => { this.accepted(); diff --git a/src/client/pages/channel-editor.vue b/src/client/pages/channel-editor.vue index 0178662119..c011acc52e 100644 --- a/src/client/pages/channel-editor.vue +++ b/src/client/pages/channel-editor.vue @@ -1,39 +1,37 @@ <template> <div> - <portal to="icon"><fa :icon="faSatelliteDish"/></portal> - <portal to="title">{{ channelId ? $t('_channel.edit') : $t('_channel.create') }}</portal> - - <div class="_card"> + <div class="_section"> <div class="_content"> - <mk-input v-model="name">{{ $t('name') }}</mk-input> + <MkInput v-model:value="name">{{ $t('name') }}</MkInput> - <mk-textarea v-model="description">{{ $t('description') }}</mk-textarea> + <MkTextarea v-model:value="description">{{ $t('description') }}</MkTextarea> <div class="banner"> - <mk-button v-if="bannerId == null" @click="setBannerImage"><fa :icon="faPlus"/> {{ $t('_channel.setBanner') }}</mk-button> + <MkButton v-if="bannerId == null" @click="setBannerImage"><Fa :icon="faPlus"/> {{ $t('_channel.setBanner') }}</MkButton> <div v-else-if="bannerUrl"> <img :src="bannerUrl" style="width: 100%;"/> - <mk-button @click="removeBannerImage()"><fa :icon="faTrashAlt"/> {{ $t('_channel.removeBanner') }}</mk-button> + <MkButton @click="removeBannerImage()"><Fa :icon="faTrashAlt"/> {{ $t('_channel.removeBanner') }}</MkButton> </div> </div> </div> <div class="_footer"> - <mk-button @click="save()" primary><fa :icon="faSave"/> {{ channelId ? $t('save') : $t('create') }}</mk-button> + <MkButton @click="save()" primary><Fa :icon="faSave"/> {{ channelId ? $t('save') : $t('create') }}</MkButton> </div> </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { computed, defineComponent } from 'vue'; import { faPlus, faSatelliteDish } from '@fortawesome/free-solid-svg-icons'; import { faSave, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; -import MkTextarea from '../components/ui/textarea.vue'; -import MkButton from '../components/ui/button.vue'; -import MkInput from '../components/ui/input.vue'; -import { selectFile } from '../scripts/select-file'; +import MkTextarea from '@/components/ui/textarea.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkTextarea, MkButton, MkInput, }, @@ -47,6 +45,17 @@ export default Vue.extend({ data() { return { + INFO: computed(() => this.channelId ? { + header: [{ + title: this.$t('_channel.edit'), + icon: faSatelliteDish, + }], + } : { + header: [{ + title: this.$t('_channel.create'), + icon: faSatelliteDish, + }], + }), channel: null, name: null, description: null, @@ -61,7 +70,7 @@ export default Vue.extend({ if (this.bannerId == null) { this.bannerUrl = null; } else { - this.bannerUrl = (await this.$root.api('drive/files/show', { + this.bannerUrl = (await os.api('drive/files/show', { fileId: this.bannerId, })).url; } @@ -70,7 +79,7 @@ export default Vue.extend({ async created() { if (this.channelId) { - this.channel = await this.$root.api('channels/show', { + this.channel = await os.api('channels/show', { channelId: this.channelId, }); @@ -91,27 +100,21 @@ export default Vue.extend({ if (this.channelId) { params.channelId = this.channelId; - this.$root.api('channels/update', params) + os.api('channels/update', params) .then(channel => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); }); } else { - this.$root.api('channels/create', params) + os.api('channels/create', params) .then(channel => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); this.$router.push(`/channels/${channel.id}`); }); } }, setBannerImage(e) { - selectFile(this, e.currentTarget || e.target, null, false).then(file => { + selectFile(e.currentTarget || e.target, null, false).then(file => { this.bannerId = file.id; }); }, diff --git a/src/client/pages/channel.vue b/src/client/pages/channel.vue index 69631af74b..305b2ab2b1 100644 --- a/src/client/pages/channel.vue +++ b/src/client/pages/channel.vue @@ -1,50 +1,42 @@ <template> <div v-if="channel"> - <portal to="icon"><fa :icon="faSatelliteDish"/></portal> - <portal to="title">{{ channel.name }}</portal> - <div class="wpgynlbz _panel _vMargin" :class="{ hide: !showBanner }"> - <x-channel-follow-button :channel="channel" :full="true" class="subscribe"/> + <XChannelFollow-button :channel="channel" :full="true" class="subscribe"/> <button class="_button toggle" @click="() => showBanner = !showBanner"> - <template v-if="showBanner"><fa :icon="faAngleUp"/></template> - <template v-else><fa :icon="faAngleDown"/></template> + <template v-if="showBanner"><Fa :icon="faAngleUp"/></template> + <template v-else><Fa :icon="faAngleDown"/></template> </button> <div class="hideOverlay" v-if="!showBanner"> </div> <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner"> <div class="status"> - <div><fa :icon="faUsers" fixed-width/><i18n path="_channel.usersCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.usersCount }}</b></i18n></div> - <div><fa :icon="faPencilAlt" fixed-width/><i18n path="_channel.notesCount" tag="span" style="margin-left: 4px;"><b place="n">{{ channel.notesCount }}</b></i18n></div> + <div><Fa :icon="faUsers" fixed-width/><i18n path="_channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></i18n></div> + <div><Fa :icon="faPencilAlt" fixed-width/><i18n path="_channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></i18n></div> </div> <div class="fade"></div> </div> <div class="description" v-if="channel.description"> - <mfm :text="channel.description" :is-note="false" :i="$store.state.i"/> + <Mfm :text="channel.description" :is-note="false" :i="$store.state.i"/> </div> </div> - <x-post-form :channel="channel" class="post-form _panel _vMargin" fixed/> + <XPostForm :channel="channel" class="post-form _panel _vMargin" fixed/> - <x-timeline class="_vMargin" src="channel" :channel="channelId" @before="before" @after="after"/> + <XTimeline class="_vMargin" src="channel" :channel="channelId" @before="before" @after="after"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { computed, defineComponent } from 'vue'; import { faSatelliteDish, faUsers, faPencilAlt, faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons'; import { } from '@fortawesome/free-regular-svg-icons'; -import MkContainer from '../components/ui/container.vue'; -import XPostForm from '../components/post-form.vue'; -import XTimeline from '../components/timeline.vue'; -import XChannelFollowButton from '../components/channel-follow-button.vue'; - -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('channel') as string - }; - }, +import MkContainer from '@/components/ui/container.vue'; +import XPostForm from '@/components/post-form.vue'; +import XTimeline from '@/components/timeline.vue'; +import XChannelFollowButton from '@/components/channel-follow-button.vue'; +import * as os from '@/os'; +export default defineComponent({ components: { MkContainer, XPostForm, @@ -61,6 +53,12 @@ export default Vue.extend({ data() { return { + INFO: computed(() => this.channel ? { + header: [{ + title: this.channel.name, + icon: faSatelliteDish, + }], + } : null), channel: null, showBanner: true, pagination: { @@ -77,7 +75,7 @@ export default Vue.extend({ watch: { channelId: { async handler() { - this.channel = await this.$root.api('channels/show', { + this.channel = await os.api('channels/show', { channelId: this.channelId, }); }, diff --git a/src/client/pages/channels.vue b/src/client/pages/channels.vue index 34a79e70eb..7d18e8cb51 100644 --- a/src/client/pages/channels.vue +++ b/src/client/pages/channels.vue @@ -1,46 +1,53 @@ <template> <div> - <portal to="icon"><fa :icon="faSatelliteDish"/></portal> - <portal to="title">{{ $t('channel') }}</portal> - - <mk-tab v-model="tab" :items="[{ label: $t('_channel.featured'), value: 'featured', icon: faFireAlt }, { label: $t('_channel.following'), value: 'following', icon: faHeart }, { label: $t('_channel.owned'), value: 'owned', icon: faEdit }]"/> - - <div class="grwlizim featured" v-if="tab === 'featured'"> - <mk-pagination :pagination="featuredPagination" #default="{items}"> - <mk-channel-preview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/> - </mk-pagination> + <div class="_section" style="padding: 0;"> + <MkTab class="_content" v-model:value="tab" :items="[{ label: $t('_channel.featured'), value: 'featured', icon: faFireAlt }, { label: $t('_channel.following'), value: 'following', icon: faHeart }, { label: $t('_channel.owned'), value: 'owned', icon: faEdit }]"/> </div> - <div class="grwlizim following" v-if="tab === 'following'"> - <mk-pagination :pagination="followingPagination" #default="{items}"> - <mk-channel-preview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/> - </mk-pagination> - </div> + <div class="_section"> + <div class="_content grwlizim featured" v-if="tab === 'featured'"> + <MkPagination :pagination="featuredPagination" #default="{items}"> + <MkChannelPreview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/> + </MkPagination> + </div> - <div class="grwlizim owned" v-if="tab === 'owned'"> - <mk-button class="new" @click="create()"><fa :icon="faPlus"/></mk-button> - <mk-pagination :pagination="ownedPagination" #default="{items}"> - <mk-channel-preview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/> - </mk-pagination> + <div class="_content grwlizim following" v-if="tab === 'following'"> + <MkPagination :pagination="followingPagination" #default="{items}"> + <MkChannelPreview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/> + </MkPagination> + </div> + + <div class="_content grwlizim owned" v-if="tab === 'owned'"> + <MkButton class="new" @click="create()"><Fa :icon="faPlus"/></MkButton> + <MkPagination :pagination="ownedPagination" #default="{items}"> + <MkChannelPreview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/> + </MkPagination> + </div> </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faSatelliteDish, faPlus, faEdit, faFireAlt } from '@fortawesome/free-solid-svg-icons'; import { faHeart } from '@fortawesome/free-regular-svg-icons'; -import MkChannelPreview from '../components/channel-preview.vue'; -import MkPagination from '../components/ui/pagination.vue'; -import MkButton from '../components/ui/button.vue'; -import MkTab from '../components/tab.vue'; +import MkChannelPreview from '@/components/channel-preview.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkTab from '@/components/tab.vue'; -export default Vue.extend({ +export default defineComponent({ components: { MkChannelPreview, MkPagination, MkButton, MkTab }, data() { return { + INFO: { + header: [{ + title: this.$t('channel'), + icon: faSatelliteDish + }] + }, tab: 'featured', featuredPagination: { endpoint: 'channels/featured', diff --git a/src/client/pages/doc.vue b/src/client/pages/doc.vue index e4c4ef5c6c..f2c70df212 100644 --- a/src/client/pages/doc.vue +++ b/src/client/pages/doc.vue @@ -1,26 +1,24 @@ <template> <div> - <portal to="icon"><fa :icon="faFileAlt"/></portal> - <portal to="title">{{ title }}</portal> - <main class="_card"> - <div class="_title"><fa :icon="faFileAlt"/> {{ title }}</div> + <main class="_section"> + <div class="_title"><Fa :icon="faFileAlt"/> {{ title }}</div> <div class="_content"> <div v-html="body" class="qyqbqfal"></div> </div> <div class="_footer"> - <mk-link :url="`https://github.com/syuilo/misskey/blob/master/src/docs/${doc}.ja-JP.md`" class="at">{{ $t('docSource') }}</mk-link> + <MkLink :url="`https://github.com/syuilo/misskey/blob/master/src/docs/${doc}.ja-JP.md`" class="at">{{ $t('docSource') }}</MkLink> </div> </main> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faFileAlt } from '@fortawesome/free-solid-svg-icons' import MarkdownIt from 'markdown-it'; import MarkdownItAnchor from 'markdown-it-anchor'; -import { url, lang } from '../config'; -import MkLink from '../components/link.vue'; +import { url, lang } from '@/config'; +import MkLink from '@/components/link.vue'; const markdown = MarkdownIt({ html: true @@ -30,13 +28,7 @@ markdown.use(MarkdownItAnchor, { slugify: (s) => encodeURIComponent(String(s).trim().replace(/\s+/g, '-')) }); -export default Vue.extend({ - metaInfo() { - return { - title: this.title, - }; - }, - +export default defineComponent({ components: { MkLink }, @@ -48,17 +40,14 @@ export default Vue.extend({ } }, - watch: { - doc: { - handler() { - this.fetchDoc(); - }, - immediate: true, - } - }, - data() { return { + INFO: { + header: [{ + title: this.title, + icon: faFileAlt + }], + }, faFileAlt, title: '', body: '', @@ -66,6 +55,15 @@ export default Vue.extend({ } }, + watch: { + doc: { + handler() { + this.fetchDoc(); + }, + immediate: true, + } + }, + methods: { fetchDoc() { fetch(`${url}/assets/docs/${this.doc}.${lang}.md`).then(res => res.text()).then(md => { @@ -120,11 +118,11 @@ export default Vue.extend({ margin-bottom: 0; } - ::v-deep a { + ::v-deep(a) { color: var(--link); } - ::v-deep blockquote { + ::v-deep(blockquote) { display: block; margin: 8px; padding: 6px 0 6px 12px; @@ -137,19 +135,19 @@ export default Vue.extend({ } } - ::v-deep h2 { + ::v-deep(h2) { font-size: 1.25em; padding: 0 0 0.5em 0; border-bottom: solid 1px var(--divider); } - ::v-deep table { + ::v-deep(table) { width: 100%; max-width: 100%; overflow: auto; } - ::v-deep kbd.group { + ::v-deep(kbd.group) { display: inline-block; padding: 2px; border: 1px solid var(--divider); @@ -157,7 +155,7 @@ export default Vue.extend({ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); } - ::v-deep kbd.key { + ::v-deep(kbd.key) { display: inline-block; padding: 6px 8px; border: solid 1px var(--divider); diff --git a/src/client/pages/docs.vue b/src/client/pages/docs.vue index a880e8abe4..ea3e16df95 100644 --- a/src/client/pages/docs.vue +++ b/src/client/pages/docs.vue @@ -1,8 +1,6 @@ <template> <div> - <portal to="icon"><fa :icon="faQuestionCircle"/></portal> - <portal to="title">{{ $t('help') }}</portal> - <main class="_card"> + <main class="_section"> <div class="_content"> <ul> <li v-for="doc in docs" :key="doc.path"> @@ -15,19 +13,19 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons' -import { url, lang } from '../config'; - -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('help') as string, - }; - }, +import { url, lang } from '@/config'; +export default defineComponent({ data() { return { + INFO: { + header: [{ + title: this.$t('help'), + icon: faQuestionCircle + }], + }, docs: [], faQuestionCircle } diff --git a/src/client/pages/drive.vue b/src/client/pages/drive.vue index 30202b7070..1456fb2922 100644 --- a/src/client/pages/drive.vue +++ b/src/client/pages/drive.vue @@ -1,87 +1,40 @@ <template> -<div class="full"> - <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> + <XDrive 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 - }; - }, +import { computed, defineComponent } from 'vue'; +import { faCloud, faEllipsisH } from '@fortawesome/free-solid-svg-icons'; +import XDrive from '@/components/drive.vue'; +import * as os from '@/os'; +export default defineComponent({ components: { XDrive }, data() { return { - menuOpened: false, + INFO: { + header: [{ + title: computed(() => this.folder ? this.folder.name : this.$t('drive')), + icon: faCloud, + }], + action: { + icon: faEllipsisH, + handler: this.menu + } + }, 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(this.folder); } - } : undefined, this.folder ? { - text: this.$t('deleteFolder'), - icon: faTrashAlt, - action: () => { this.$refs.drive.deleteFolder(this.folder); } - } : 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; - }); + os.modalMenu(this.$refs.drive.getMenu(), ev.currentTarget || ev.target); } } }); </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 index 39904846cf..cf191a7481 100644 --- a/src/client/pages/explore.vue +++ b/src/client/pages/explore.vue @@ -1,75 +1,86 @@ <template> <div> - <portal to="icon"><fa :icon="faHashtag"/></portal> - <portal to="title">{{ $t('explore') }}</portal> + <div class="_section"> + <MkInput v-model:value="query" :debounce="true" type="search"><template #icon><Fa :icon="faSearch"/></template><span>{{ $t('searchUser') }}</span></MkInput> - <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> + <XUserList v-if="query" class="_vMargin" :pagination="searchPagination" ref="search"/> - <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 _vMargin" 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> - <div class="localfedi7 _panel" v-if="tag == null" :style="{ backgroundImage: `url(/assets/fedi.jpg)`, marginTop: 'var(--margin)' }"> - <header><span>{{ $t('exploreFediverse') }}</span></header> + <template v-if="tag == null"> + <MkFolder class="_vMargin" persist-key="explore-pinned-users"> + <template #header><Fa :icon="faBookmark" fixed-width style="margin-right: 0.5em;"/>{{ $t('pinnedUsers') }}</template> + <XUserList :pagination="pinnedUsers"/> + </MkFolder> + <MkFolder class="_vMargin" persist-key="explore-popular-users"> + <template #header><Fa :icon="faChartLine" fixed-width style="margin-right: 0.5em;"/>{{ $t('popularUsers') }}</template> + <XUserList :pagination="popularUsers"/> + </MkFolder> + <MkFolder class="_vMargin" persist-key="explore-recently-updated-users"> + <template #header><Fa :icon="faCommentAlt" fixed-width style="margin-right: 0.5em;"/>{{ $t('recentlyUpdatedUsers') }}</template> + <XUserList :pagination="recentlyUpdatedUsers"/> + </MkFolder> + <MkFolder class="_vMargin" persist-key="explore-recently-registered-users"> + <template #header><Fa :icon="faPlus" fixed-width style="margin-right: 0.5em;"/>{{ $t('recentlyRegisteredUsers') }}</template> + <XUserList :pagination="recentlyRegisteredUsers"/> + </MkFolder> + </template> </div> + <div class="_section"> + <div class="localfedi7 _panel _vMargin" v-if="tag == null" :style="{ backgroundImage: `url(/assets/fedi.jpg)` }"> + <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> + <MkFolder :body-togglable="true" :expanded="false" ref="tags" class="_vMargin"> + <template #header><Fa :icon="faHashtag" fixed-width style="margin-right: 0.5em;"/>{{ $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> + <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> + </MkFolder> - <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> + <MkFolder v-if="tag != null" :key="`${tag}`" class="_vMargin"> + <template #header><Fa :icon="faHashtag" fixed-width style="margin-right: 0.5em;"/>{{ tag }}</template> + <XUserList :pagination="tagUsers"/> + </MkFolder> + + <template v-if="tag == null"> + <MkFolder class="_vMargin"> + <template #header><Fa :icon="faChartLine" fixed-width style="margin-right: 0.5em;"/>{{ $t('popularUsers') }}</template> + <XUserList :pagination="popularUsersF"/> + </MkFolder> + <MkFolder class="_vMargin"> + <template #header><Fa :icon="faCommentAlt" fixed-width style="margin-right: 0.5em;"/>{{ $t('recentlyUpdatedUsers') }}</template> + <XUserList :pagination="recentlyUpdatedUsersF"/> + </MkFolder> + <MkFolder class="_vMargin"> + <template #header><Fa :icon="faRocket" fixed-width style="margin-right: 0.5em;"/>{{ $t('recentlyDiscoveredUsers') }}</template> + <XUserList :pagination="recentlyRegisteredUsersF"/> + </MkFolder> + </template> + </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; -import { faChartLine, faPlus, faHashtag, faRocket } from '@fortawesome/free-solid-svg-icons'; +import { computed, defineComponent } from 'vue'; +import { faChartLine, faPlus, faHashtag, faRocket, faSearch } from '@fortawesome/free-solid-svg-icons'; import { faBookmark, faCommentAlt } from '@fortawesome/free-regular-svg-icons'; -import XUserList from '../components/user-list.vue'; -import MkContainer from '../components/ui/container.vue'; - -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('explore') as string - }; - }, +import XUserList from '@/components/user-list.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import MkInput from '@/components/ui/input.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +export default defineComponent({ components: { XUserList, - MkContainer, + MkFolder, + MkInput, }, props: { @@ -81,6 +92,12 @@ export default Vue.extend({ data() { return { + INFO: { + header: [{ + title: this.$t('explore'), + icon: faHashtag + }], + }, pinnedUsers: { endpoint: 'pinned-users' }, popularUsers: { endpoint: 'users', limit: 10, noPaging: true, params: { state: 'alive', @@ -109,11 +126,19 @@ export default Vue.extend({ origin: 'combined', sort: '+createdAt', } }, + searchPagination: { + endpoint: 'users/search', + limit: 10, + params: computed(() => (this.query && this.query !== '') ? { + query: this.query + } : null) + }, tagsLocal: [], tagsRemote: [], stats: null, - num: Vue.filter('number'), - faBookmark, faChartLine, faCommentAlt, faPlus, faHashtag, faRocket + query: null, + num: number, + faBookmark, faChartLine, faCommentAlt, faPlus, faHashtag, faRocket, faSearch, }; }, @@ -137,25 +162,25 @@ export default Vue.extend({ watch: { tag() { if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null); - } + }, }, created() { - this.$root.api('hashtags/list', { + os.api('hashtags/list', { sort: '+attachedLocalUsers', attachedToLocalUserOnly: true, limit: 30 }).then(tags => { this.tagsLocal = tags; }); - this.$root.api('hashtags/list', { + os.api('hashtags/list', { sort: '+attachedRemoteUsers', attachedToRemoteUserOnly: true, limit: 30 }).then(tags => { this.tagsRemote = tags; }); - this.$root.api('stats').then(stats => { + os.api('stats').then(stats => { this.stats = stats; }); }, @@ -195,8 +220,6 @@ export default Vue.extend({ } .vxjfqztj { - padding: 16px; - > * { margin-right: 16px; diff --git a/src/client/pages/favorites.vue b/src/client/pages/favorites.vue index 0e625f84cf..4360dc6ec3 100644 --- a/src/client/pages/favorites.vue +++ b/src/client/pages/favorites.vue @@ -1,37 +1,35 @@ <template> -<div> - <portal to="icon"><fa :icon="faStar"/></portal> - <portal to="title">{{ $t('favorites') }}</portal> - <x-notes :pagination="pagination" :detail="true" :prop="'note'" @before="before()" @after="after()"/> +<div class="_section"> + <XNotes class="_content" :pagination="pagination" :detail="true" :prop="'note'" @before="before()" @after="after()"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } 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 - }; - }, +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +import * as os from '@/os'; +export default defineComponent({ components: { XNotes }, data() { return { + INFO: { + header: [{ + title: this.$t('favorites'), + icon: faStar + }] + }, pagination: { endpoint: 'i/favorites', limit: 10, params: () => ({ }) }, - faStar }; }, diff --git a/src/client/pages/featured.vue b/src/client/pages/featured.vue index e6293e9e83..c3a9e25105 100644 --- a/src/client/pages/featured.vue +++ b/src/client/pages/featured.vue @@ -1,30 +1,28 @@ <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 class="_section"> + <XNotes class="_content" ref="notes" :pagination="pagination" @before="before" @after="after"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } 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 - }; - }, +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +export default defineComponent({ components: { XNotes }, data() { return { + INFO: { + header: [{ + title: this.$t('featured'), + icon: faFireAlt + }], + }, pagination: { endpoint: 'notes/featured', limit: 10, diff --git a/src/client/pages/follow-requests.vue b/src/client/pages/follow-requests.vue index b310d9f581..86e409ebbd 100644 --- a/src/client/pages/follow-requests.vue +++ b/src/client/pages/follow-requests.vue @@ -1,9 +1,6 @@ <template> <div> - <portal to="icon"><fa :icon="faUserClock"/></portal> - <portal to="title">{{ $t('followRequests') }}</portal> - - <mk-pagination :pagination="pagination" class="mk-follow-requests" ref="list"> + <MkPagination :pagination="pagination" class="mk-follow-requests" ref="list"> <template #empty> <div class="_fullinfo"> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> @@ -12,44 +9,46 @@ </template> <template #default="{items}"> <div class="user _panel" v-for="req in items" :key="req.id"> - <mk-avatar class="avatar" :user="req.follower"/> + <MkAvatar 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> + <router-link class="name" :to="userPage(req.follower)" v-user-preview="req.follower.id"><MkUserName :user="req.follower"/></router-link> + <p class="acct">@{{ acct(req.follower) }}</p> </div> <div class="description" v-if="req.follower.description" :title="req.follower.description"> - <mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$store.state.i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/> + <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> + <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> </template> - </mk-pagination> + </MkPagination> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faUserClock, 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 - }; - }, +import MkPagination from '@/components/ui/pagination.vue'; +import { userPage, acct } from '../filters/user'; +import * as os from '@/os'; +export default defineComponent({ components: { MkPagination }, data() { return { + INFO: { + header: [{ + title: this.$t('followRequests'), + icon: faUserClock, + }], + }, pagination: { endpoint: 'following/requests/list', limit: 10, @@ -60,15 +59,17 @@ export default Vue.extend({ methods: { accept(user) { - this.$root.api('following/requests/accept', { userId: user.id }).then(() => { + os.api('following/requests/accept', { userId: user.id }).then(() => { this.$refs.list.reload(); }); }, reject(user) { - this.$root.api('following/requests/reject', { userId: user.id }).then(() => { + os.api('following/requests/reject', { userId: user.id }).then(() => { this.$refs.list.reload(); }); - } + }, + userPage, + acct } }); </script> diff --git a/src/client/pages/follow.vue b/src/client/pages/follow.vue index 8659763bb7..35d5cc3b26 100644 --- a/src/client/pages/follow.vue +++ b/src/client/pages/follow.vue @@ -4,14 +4,15 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ created() { const acct = new URL(location.href).searchParams.get('acct'); if (acct == null) return; - const dialog = this.$root.dialog({ + const dialog = os.dialog({ type: 'waiting', text: this.$t('fetchingAsApObject') + '...', showOkButton: false, @@ -20,13 +21,13 @@ export default Vue.extend({ }); if (acct.startsWith('https://')) { - this.$root.api('ap/show', { + os.api('ap/show', { uri: acct }).then(res => { if (res.type == 'User') { this.follow(res.object); } else { - this.$root.dialog({ + os.dialog({ type: 'error', text: 'Not a user' }).then(() => { @@ -34,7 +35,7 @@ export default Vue.extend({ }); } }).catch(e => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e }).then(() => { @@ -44,10 +45,10 @@ export default Vue.extend({ dialog.close(); }); } else { - this.$root.api('users/show', parseAcct(acct)).then(user => { + os.api('users/show', parseAcct(acct)).then(user => { this.follow(user); }).catch(e => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e }).then(() => { @@ -61,7 +62,7 @@ export default Vue.extend({ methods: { async follow(user) { - const { canceled } = await this.$root.dialog({ + const { canceled } = await os.dialog({ type: 'question', text: this.$t('followConfirm', { name: user.name || user.username }), showCancelButton: true @@ -72,17 +73,14 @@ export default Vue.extend({ return; } - this.$root.api('following/create', { + os.api('following/create', { userId: user.id }).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }).then(() => { + os.success().then(() => { window.close(); }); }).catch(e => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e }).then(() => { diff --git a/src/client/pages/index.vue b/src/client/pages/index.vue deleted file mode 100644 index 788df3929a..0000000000 --- a/src/client/pages/index.vue +++ /dev/null @@ -1,31 +0,0 @@ -<template> -<component :is="$store.getters.isSignedIn ? 'home' : 'welcome'" :show-title="showTitle"></component> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import Home from './index.home.vue'; - -export default Vue.extend({ - name: 'index', - - components: { - Home, - Welcome: () => import('./index.welcome.vue').then(m => m.default), - }, - - data() { - return { - showTitle: true, - } - }, - - activated() { - this.showTitle = true; - }, - - deactivated() { - this.showTitle = false; - } -}); -</script> diff --git a/src/client/pages/index.welcome.entrance.vue b/src/client/pages/index.welcome.entrance.vue deleted file mode 100644 index 9bb2e85fc3..0000000000 --- a/src/client/pages/index.welcome.entrance.vue +++ /dev/null @@ -1,95 +0,0 @@ -<template> -<div class="rsqzvsbo"> - <div class="_panel about" v-if="meta"> - <div class="banner" :style="{ backgroundImage: `url(${ meta.bannerUrl })` }"></div> - <div class="body"> - <h1 class="name" v-html="meta.name || host"></h1> - <div class="desc" v-html="meta.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 { host } from '../config'; - -export default Vue.extend({ - components: { - MkButton, - XNotes, - }, - - data() { - return { - featuredPagination: { - endpoint: 'notes/featured', - limit: 10, - noPaging: true, - }, - host: toUnicode(host), - }; - }, - - computed: { - meta() { - return this.$store.state.instance.meta; - }, - }, - - created() { - this.$root.api('stats').then(stats => { - this.stats = stats; - }); - }, - - methods: { - signin() { - this.$root.new(XSigninDialog, { - autoSet: true - }); - }, - - signup() { - this.$root.new(XSignupDialog, { - autoSet: true - }); - } - } -}); -</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.vue b/src/client/pages/index.welcome.vue deleted file mode 100644 index fb4aba6588..0000000000 --- a/src/client/pages/index.welcome.vue +++ /dev/null @@ -1,33 +0,0 @@ -<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 { instanceName } from '../config'; - -export default Vue.extend({ - components: { - XSetup, - XEntrance, - }, - - data() { - return { - instanceName: instanceName || 'Misskey', - } - }, - - computed: { - meta() { - return this.$store.state.instance.meta; - }, - }, -}); -</script> diff --git a/src/client/pages/instance/announcements.vue b/src/client/pages/instance/announcements.vue index 0e11e2932e..7abec88042 100644 --- a/src/client/pages/instance/announcements.vue +++ b/src/client/pages/instance/announcements.vue @@ -1,44 +1,41 @@ <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="_card announcements"> - <div class="_content announcement" v-for="announcement in announcements"> - <mk-input v-model="announcement.title"> - <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 class="_section"> + <div class="_content"> + <MkButton @click="add()" primary style="margin: 0 auto 16px auto;"><Fa :icon="faPlus"/> {{ $t('add') }}</MkButton> + <section class="_card _vMargin announcements" v-for="announcement in announcements"> + <div class="_content announcement"> + <MkInput v-model:value="announcement.title"> + <span>{{ $t('title') }}</span> + </MkInput> + <MkTextarea v-model:value="announcement.text"> + <span>{{ $t('text') }}</span> + </MkTextarea> + <MkInput v-model:value="announcement.imageUrl"> + <span>{{ $t('imageUrl') }}</span> + </MkInput> + <p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p> + <div class="buttons"> + <MkButton class="button" inline @click="save(announcement)" primary><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> + <MkButton class="button" inline @click="remove(announcement)"><Fa :icon="faTrashAlt"/> {{ $t('remove') }}</MkButton> + </div> + </div> + </section> </div> - </section> + </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faBroadcastTower, faPlus } from '@fortawesome/free-solid-svg-icons'; import { faSave, faTrashAlt } 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'; - -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('announcements') as string - }; - }, +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import * as os from '@/os'; +export default defineComponent({ components: { MkButton, MkInput, @@ -47,13 +44,19 @@ export default Vue.extend({ data() { return { + INFO: { + header: [{ + title: this.$t('announcements'), + icon: faBroadcastTower + }] + }, announcements: [], faBroadcastTower, faSave, faTrashAlt, faPlus } }, created() { - this.$root.api('admin/announcements/list').then(announcements => { + os.api('admin/announcements/list').then(announcements => { this.announcements = announcements; }); }, @@ -69,38 +72,38 @@ export default Vue.extend({ }, remove(announcement) { - this.$root.dialog({ + os.dialog({ type: 'warning', text: this.$t('removeAreYouSure', { x: announcement.title }), showCancelButton: true }).then(({ canceled }) => { if (canceled) return; this.announcements = this.announcements.filter(x => x != announcement); - this.$root.api('admin/announcements/delete', announcement); + os.api('admin/announcements/delete', announcement); }); }, save(announcement) { if (announcement.id == null) { - this.$root.api('admin/announcements/create', announcement).then(() => { - this.$root.dialog({ + os.api('admin/announcements/create', announcement).then(() => { + os.dialog({ type: 'success', text: this.$t('saved') }); }).catch(e => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e }); }); } else { - this.$root.api('admin/announcements/update', announcement).then(() => { - this.$root.dialog({ + os.api('admin/announcements/update', announcement).then(() => { + os.dialog({ type: 'success', text: this.$t('saved') }); }).catch(e => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e }); @@ -110,17 +113,3 @@ export default Vue.extend({ } }); </script> - -<style lang="scss" scoped> -.ztgjmzrw { - > .announcements { - > .announcement { - > .buttons { - > .button:first-child { - margin-right: 8px; - } - } - } - } -} -</style> diff --git a/src/client/pages/instance/emoji-edit-dialog.vue b/src/client/pages/instance/emoji-edit-dialog.vue new file mode 100644 index 0000000000..ed81f15f6e --- /dev/null +++ b/src/client/pages/instance/emoji-edit-dialog.vue @@ -0,0 +1,116 @@ +<template> +<XModalWindow ref="dialog" + :width="370" + :with-ok-button="true" + @close="$refs.dialog.close()" + @closed="$emit('closed')" + @ok="ok()" +> + <template #header>:{{ emoji.name }}:</template> + + <div class="yigymqpb _section"> + <img :src="emoji.url" class="img"/> + <MkInput v-model:value="name"><span>{{ $t('name') }}</span></MkInput> + <MkInput v-model:value="category" :datalist="categories"><span>{{ $t('category') }}</span></MkInput> + <MkInput v-model:value="aliases"> + <span>{{ $t('tags') }}</span> + <template #desc>{{ $t('setMultipleBySeparatingWithSpace') }}</template> + </MkInput> + <MkButton danger @click="del()"><Fa :icon="faTrashAlt"/> {{ $t('delete') }}</MkButton> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import * as os from '@/os'; +import { unique } from '../../../prelude/array'; + +export default defineComponent({ + components: { + XModalWindow, + MkButton, + MkInput, + }, + + props: { + emoji: { + required: true, + } + }, + + emits: ['done', 'closed'], + + data() { + return { + name: this.emoji.name, + category: this.emoji.category, + aliases: this.emoji.aliases?.join(' '), + categories: [], + faTrashAlt, + } + }, + + created() { + os.api('meta', { detail: false }).then(({ emojis }) => { + this.categories = unique(emojis.map((x: any) => x.category || '').filter((x: string) => x !== '')); + }); + }, + + methods: { + ok() { + this.update(); + }, + + async update() { + await os.apiWithDialog('admin/emoji/update', { + id: this.emoji.id, + name: this.name, + category: this.category, + aliases: this.aliases.split(' '), + }); + + this.$emit('done', { + updated: { + name: this.name, + category: this.category, + aliases: this.aliases.split(' '), + } + }); + this.$refs.dialog.close(); + }, + + async del() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: this.emoji.name }), + showCancelButton: true + }); + if (canceled) return; + + os.api('admin/emoji/remove', { + id: this.emoji.id + }).then(() => { + this.$emit('done', { + deleted: true + }); + this.$refs.dialog.close(); + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.yigymqpb { + > .img { + display: block; + height: 64px; + margin: 0 auto; + } +} +</style> diff --git a/src/client/pages/instance/emojis.vue b/src/client/pages/instance/emojis.vue index 25897ea7d9..465a9ebe00 100644 --- a/src/client/pages/instance/emojis.vue +++ b/src/client/pages/instance/emojis.vue @@ -1,80 +1,67 @@ <template> <div class="mk-instance-emojis"> - <portal to="icon"><fa :icon="faLaugh"/></portal> - <portal to="title">{{ $t('customEmojis') }}</portal> + <div class="_section" style="padding: 0;"> + <MkTab v-model:value="tab" :items="[{ label: $t('local'), value: 'local' }, { label: $t('remote'), value: 'remote' }]"/> + </div> - <section class="_card _vMargin local"> - <div class="_title"><fa :icon="faLaugh"/> {{ $t('customEmojis') }}</div> - <div class="_content"> - <mk-pagination :pagination="pagination" class="emojis" ref="emojis"> + <div class="_section"> + <div class="_content local" v-if="tab === 'local'"> + <MkButton primary @click="add" style="margin: 0 auto var(--margin) auto;"><Fa :icon="faPlus"/> {{ $t('addEmoji') }}</MkButton> + <MkInput v-model:value="query" :debounce="true" type="search"><template #icon><Fa :icon="faSearch"/></template><span>{{ $t('search') }}</span></MkInput> + <MkPagination :pagination="pagination" ref="emojis"> <template #empty><span>{{ $t('noCustomEmojis') }}</span></template> <template #default="{items}"> - <div class="emoji" v-for="(emoji, i) in items" :key="emoji.id" @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> - <span class="info"> - <b class="category">{{ emoji.category }}</b> - <span class="aliases">{{ emoji.aliases.join(' ') }}</span> - </span> - </div> + <div class="emojis"> + <button class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="edit(emoji)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <span class="name">{{ emoji.name }}</span> + <span class="info"> + <span class="category">{{ emoji.category }}</span> + </span> + </div> + </button> </div> </template> - </mk-pagination> - </div> - <div class="_content" v-if="selected"> - <mk-input v-model="name"><span>{{ $t('name') }}</span></mk-input> - <mk-input v-model="category" :datalist="categories"><span>{{ $t('category') }}</span></mk-input> - <mk-input v-model="aliases"><span>{{ $t('tags') }}</span></mk-input> - <mk-button inline primary @click="update"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> - <mk-button inline :disabled="selected == null" @click="del()"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</mk-button> - </div> - <div class="_footer"> - <mk-button inline primary @click="add"><fa :icon="faPlus"/> {{ $t('addEmoji') }}</mk-button> + </MkPagination> </div> - </section> - <section class="_card _vMargin remote"> - <div class="_title"><fa :icon="faLaugh"/> {{ $t('customEmojisOfRemote') }}</div> - <div class="_content"> - <mk-input v-model="host" :debounce="true"><span>{{ $t('host') }}</span></mk-input> - <mk-pagination :pagination="remotePagination" class="emojis" ref="remoteEmojis"> + + <div class="_content remote" v-else-if="tab === 'remote'"> + <MkInput v-model:value="queryRemote" :debounce="true" type="search"><template #icon><Fa :icon="faSearch"/></template><span>{{ $t('search') }}</span></MkInput> + <MkInput v-model:value="host" :debounce="true"><span>{{ $t('host') }}</span></MkInput> + <MkPagination :pagination="remotePagination" ref="remoteEmojis"> <template #empty><span>{{ $t('noCustomEmojis') }}</span></template> <template #default="{items}"> - <div class="emoji" v-for="(emoji, i) in items" :key="emoji.id" @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="info">{{ emoji.host }}</span> + <div class="emojis"> + <div class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="remoteMenu(emoji, $event)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <span class="name">{{ emoji.name }}</span> + <span class="info">{{ emoji.host }}</span> + </div> </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> + </MkPagination> </div> - </section> + </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; -import { faPlus, faSave } from '@fortawesome/free-solid-svg-icons'; +import { computed, defineComponent } from 'vue'; +import { faPlus, faSave, faSearch } 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 { selectFile } from '../../scripts/select-file'; -import { unique } from '../../../prelude/array'; - -export default Vue.extend({ - metaInfo() { - return { - title: `${this.$t('customEmojis')} | ${this.$t('instance')}` - }; - }, +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkTab from '@/components/tab.vue'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +export default defineComponent({ components: { + MkTab, MkButton, MkInput, MkPagination, @@ -82,54 +69,44 @@ export default Vue.extend({ data() { return { - selected: null, - selectedRemote: null, - name: null, - category: null, - aliases: null, + INFO: { + header: [{ + title: this.$t('customEmojis'), + icon: faLaugh + }], + action: { + icon: faPlus, + handler: this.add + } + }, + tab: 'local', + query: null, + queryRemote: null, host: '', pagination: { endpoint: 'admin/emoji/list', - limit: 10, + limit: 15, + params: computed(() => ({ + query: (this.query && this.query !== '') ? this.query : null + })) }, remotePagination: { endpoint: 'admin/emoji/list-remote', - limit: 10, - params: () => ({ - host: this.host ? this.host : null - }) + limit: 15, + params: computed(() => ({ + query: (this.queryRemote && this.queryRemote !== '') ? this.queryRemote : null, + host: (this.host && this.host !== '') ? this.host : null + })) }, - faTrashAlt, faPlus, faLaugh, faSave - } - }, - - computed: { - categories() { - if (this.$store.state.instance.meta) { - return unique(this.$store.state.instance.meta.emojis.map((x: any) => x.category || '').filter((x: string) => x !== '')); - } else { - return []; - } - } - }, - - watch: { - host() { - this.$refs.remoteEmojis.reload(); - }, - - selected() { - this.name = this.selected ? this.selected.name : null; - this.category = this.selected ? this.selected.category : null; - this.aliases = this.selected ? this.selected.aliases.join(' ') : null; + faTrashAlt, faPlus, faLaugh, faSave, faSearch, } }, methods: { async add(e) { - const files = await selectFile(this, e.currentTarget || e.target, null, true); + const files = await selectFile(e.currentTarget || e.target, null, true); - const dialog = this.$root.dialog({ + const dialog = os.dialog({ type: 'waiting', text: this.$t('doing') + '...', showOkButton: false, @@ -137,133 +114,112 @@ export default Vue.extend({ cancelableByBgClick: false }); - Promise.all(files.map(file => this.$root.api('admin/emoji/add', { + Promise.all(files.map(file => os.api('admin/emoji/add', { fileId: file.id, }))) .then(() => { this.$refs.emojis.reload(); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); }) .finally(() => { - dialog.close(); + dialog.cancel(); }); }, - async update() { - await this.$root.api('admin/emoji/update', { - id: this.selected.id, - name: this.name, - category: this.category, - aliases: this.aliases.split(' '), - }); - - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - - this.$refs.emojis.reload(); + async edit(emoji) { + os.popup(await import('./emoji-edit-dialog.vue'), { + emoji: emoji + }, { + done: result => { + if (result.updated) { + this.$refs.emojis.replaceItem(item => item.id === emoji.id, { + ...emoji, + ...result.updated + }); + } else if (result.deleted) { + this.$refs.emojis.removeItem(item => item.id === emoji.id); + } + }, + }, 'closed'); }, - 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(emoji) { + os.apiWithDialog('admin/emoji/copy', { + emojiId: emoji.id, }); }, - 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 - }); - }); - }, + remoteMenu(emoji, ev) { + os.modalMenu([{ + type: 'label', + text: ':' + emoji.name + ':', + }, { + text: this.$t('import'), + icon: faPlus, + action: () => { this.im(emoji) } + }], ev.currentTarget || ev.target); + } } }); </script> <style lang="scss" scoped> .mk-instance-emojis { - > .local { - > ._content { - max-height: 300px; - overflow: auto; - - > .emojis { + > ._section { + > .local { + .emojis { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: var(--margin); + > .emoji { display: flex; align-items: center; + padding: 12px; + text-align: left; - &.selected { - background: var(--accent); - box-shadow: 0 0 0 8px var(--accent); - color: #fff; + &:hover { + color: var(--accent); } > .img { - width: 50px; - height: 50px; + width: 42px; + height: 42px; } > .body { - padding: 8px; + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; > .name { display: block; + text-overflow: ellipsis; + overflow: hidden; } > .info { opacity: 0.5; - - > .category { - margin-right: 16px; - } - - > .aliases { - font-style: oblique; - } } } } } } - } - > .remote { - > ._content { - max-height: 300px; - overflow: auto; - - > .emojis { + > .remote { + .emojis { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: var(--margin); + > .emoji { display: flex; align-items: center; + padding: 12px; + text-align: left; - &.selected { - background: var(--accent); - box-shadow: 0 0 0 8px var(--accent); - color: #fff; + &:hover { + color: var(--accent); } > .img { @@ -272,14 +228,21 @@ export default Vue.extend({ } > .body { - padding: 0 8px; + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; > .name { display: block; + text-overflow: ellipsis; + overflow: hidden; } > .info { opacity: 0.5; + display: block; + text-overflow: ellipsis; + overflow: hidden; } } } diff --git a/src/client/pages/instance/federation.vue b/src/client/pages/instance/federation.vue index 8c5cbe2ff3..f2143fa003 100644 --- a/src/client/pages/instance/federation.vue +++ b/src/client/pages/instance/federation.vue @@ -1,13 +1,10 @@ <template> -<div class="mk-federation"> - <portal to="icon"><fa :icon="faGlobe"/></portal> - <portal to="title">{{ $t('federation') }}</portal> - - <section class="_card instances"> +<div> + <div class="_section"> <div class="_content"> - <mk-input v-model="host" :debounce="true"><span>{{ $t('host') }}</span></mk-input> + <MkInput v-model:value="host" :debounce="true"><span>{{ $t('host') }}</span></MkInput> <div class="inputs" style="display: flex;"> - <mk-select v-model="state" style="margin: 0; flex: 1;"> + <MkSelect v-model:value="state" style="margin: 0; flex: 1;"> <template #label>{{ $t('state') }}</template> <option value="all">{{ $t('all') }}</option> <option value="federating">{{ $t('federating') }}</option> @@ -16,8 +13,8 @@ <option value="suspended">{{ $t('suspended') }}</option> <option value="blocked">{{ $t('blocked') }}</option> <option value="notResponding">{{ $t('notResponding') }}</option> - </mk-select> - <mk-select v-model="sort" style="margin: 0; flex: 1;"> + </MkSelect> + <MkSelect v-model:value="sort" style="margin: 0; flex: 1;"> <template #label>{{ $t('sort') }}</template> <option value="+pubSub">{{ $t('pubSub') }} ({{ $t('descendingOrder') }})</option> <option value="-pubSub">{{ $t('pubSub') }} ({{ $t('ascendingOrder') }})</option> @@ -37,44 +34,41 @@ <option value="-driveUsage">{{ $t('driveUsage') }} ({{ $t('ascendingOrder') }})</option> <option value="+driveFiles">{{ $t('driveFiles') }} ({{ $t('descendingOrder') }})</option> <option value="-driveFiles">{{ $t('driveFiles') }} ({{ $t('ascendingOrder') }})</option> - </mk-select> + </MkSelect> </div> </div> + </div> + <div class="_section"> <div class="_content"> - <mk-pagination :pagination="pagination" #default="{items}" class="instances" ref="instances" :key="host + state"> - <div class="instance" v-for="instance in items" :key="instance.id" @click="info(instance)"> - <div class="host"><fa :icon="faCircle" class="indicator" :class="getStatus(instance)"/><b>{{ instance.host }}</b></div> + <MkPagination :pagination="pagination" #default="{items}" ref="instances" :key="host + state"> + <div class="ppgwaixt _panel" v-for="instance in items" :key="instance.id" @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> + <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"/><MkTime :time="instance.lastCommunicatedAt"/></span> + <span class="latestStatus"><Fa :icon="faTrafficLight" class="icon"/>{{ instance.latestStatus || '-' }}</span> </div> </div> - </mk-pagination> + </MkPagination> </div> - </section> + </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faGlobe, faCircle, faExchangeAlt, faCaretDown, faCaretUp, faTrafficLight } from '@fortawesome/free-solid-svg-icons'; -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 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 './instance.vue'; +import * as os from '@/os'; -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('federation') as string - }; - }, - +export default defineComponent({ components: { MkButton, MkInput, @@ -84,6 +78,12 @@ export default Vue.extend({ data() { return { + INFO: { + header: [{ + title: this.$t('federation'), + icon: faGlobe + }], + }, host: '', state: 'federating', sort: '+pubSub', @@ -125,60 +125,57 @@ export default Vue.extend({ }, info(instance) { - this.$root.new(MkInstanceInfo, { + os.popup(MkInstanceInfo, { instance: instance - }); + }, {}, 'closed'); } } }); </script> <style lang="scss" scoped> -.mk-federation { - > .instances { - > ._content { - > .instances { - > .instance { - cursor: pointer; +.ppgwaixt { + cursor: pointer; + padding: 16px; - > .host { - > .indicator { - font-size: 70%; - vertical-align: baseline; - margin-right: 4px; + &:hover { + color: var(--accent); + } - &.green { - color: #49c5ba; - } + > .host { + > .indicator { + font-size: 70%; + vertical-align: baseline; + margin-right: 4px; - &.yellow { - color: #c5a549; - } + &.green { + color: #49c5ba; + } - &.red { - color: #c54949; - } + &.yellow { + color: #c5a549; + } + + &.red { + color: #c54949; + } - &.off { - color: rgba(0, 0, 0, 0.5); - } - } - } + &.off { + color: rgba(0, 0, 0, 0.5); + } + } + } - > .status { - display: flex; - align-items: center; - font-size: 90%; + > .status { + display: flex; + align-items: center; + font-size: 90%; - > span { - flex: 1; - - > .icon { - margin-right: 6px; - } - } - } - } + > span { + flex: 1; + + > .icon { + margin-right: 6px; } } } diff --git a/src/client/pages/instance/file-dialog.vue b/src/client/pages/instance/file-dialog.vue new file mode 100644 index 0000000000..c03a691bfd --- /dev/null +++ b/src/client/pages/instance/file-dialog.vue @@ -0,0 +1,136 @@ +<template> +<XModalWindow ref="dialog" + :width="370" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> + <template #header v-if="file">{{ file.name }}</template> + <div class="cxqhhsmd" v-if="file"> + <div class="_section"> + <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> + <div class="info"> + <span style="margin-right: 1em;">{{ file.type }}</span> + <span>{{ bytes(file.size) }}</span> + <MkTime :time="file.createdAt" mode="detail" style="display: block;"/> + </div> + </div> + <div class="_section"> + <div class="_content"> + <MkSwitch @update:value="toggleIsSensitive" v-model:value="isSensitive">NSFW</MkSwitch> + </div> + </div> + <div class="_section"> + <div class="_content"> + <MkButton full @click="showUser"><Fa :icon="faExternalLinkSquareAlt"/> {{ $t('user') }}</MkButton> + <MkButton full danger @click="del"><Fa :icon="faTrashAlt"/> {{ $t('delete') }}</MkButton> + </div> + </div> + <div class="_section" v-if="info"> + <details class="_content rawdata"> + <pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre> + </details> + </div> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import { faTimes, faBookmark, faKey, faSync, faMicrophoneSlash, faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons'; +import { faSnowflake, faTrashAlt, faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; +import Progress from '@/scripts/loading'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkSwitch, + XModalWindow, + MkDriveFileThumbnail, + }, + + props: { + fileId: { + required: true, + } + }, + + emits: ['closed'], + + data() { + return { + file: null, + info: null, + isSensitive: false, + faTimes, faBookmark, farBookmark, faKey, faSync, faMicrophoneSlash, faSnowflake, faTrashAlt, faExternalLinkSquareAlt + }; + }, + + created() { + this.fetch(); + }, + + methods: { + async fetch() { + Progress.start(); + this.file = await os.api('drive/files/show', { fileId: this.fileId }); + this.info = await os.api('admin/drive/show-file', { fileId: this.fileId }); + this.isSensitive = this.file.isSensitive; + Progress.done(); + }, + + async showUser() { + os.popup(await import('./user-dialog.vue'), { + userId: this.file.userId + }, {}, 'closed'); + }, + + async del() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: this.file.name }), + showCancelButton: true + }); + if (canceled) return; + + os.api('drive/files/delete', { + fileId: this.file.id + }).then(() => { + this.$refs.files.removeItem(x => x.id === this.file.id); + }); + }, + + async toggleIsSensitive(v) { + await os.api('drive/files/update', { fileId: this.fileId, isSensitive: v }); + this.isSensitive = v; + }, + + bytes + } +}); +</script> + +<style lang="scss" scoped> +.cxqhhsmd { + > ._section { + > .thumbnail { + height: 150px; + max-width: 100%; + } + + > .info { + text-align: center; + margin-top: 8px; + } + + > .rawdata { + overflow: auto; + } + } +} +</style> diff --git a/src/client/pages/instance/files.vue b/src/client/pages/instance/files.vue index 0bc1c81e6f..ea90e3b5cd 100644 --- a/src/client/pages/instance/files.vue +++ b/src/client/pages/instance/files.vue @@ -1,54 +1,190 @@ <template> -<section class="_card"> - <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 class="xrmjdkdw"> + <div class="_section"> + <div class="_content"> + <MkButton primary @click="clear()"><Fa :icon="faTrashAlt"/> {{ $t('clearCachedFiles') }}</MkButton> + </div> </div> -</section> + + <div class="_section lookup"> + <div class="_title"><Fa :icon="faSearch"/> {{ $t('lookup') }}</div> + <div class="_content"> + <MkInput class="target" v-model:value="q" type="text" @enter="find()"> + <span>{{ $t('fileIdOrUrl') }}</span> + </MkInput> + <MkButton @click="find()" primary><Fa :icon="faSearch"/> {{ $t('lookup') }}</MkButton> + </div> + </div> + + <div class="_section"> + <div class="_content"> + <div class="inputs" style="display: flex;"> + <MkSelect v-model:value="origin" style="margin: 0; flex: 1;"> + <template #label>{{ $t('instance') }}</template> + <option value="combined">{{ $t('all') }}</option> + <option value="local">{{ $t('local') }}</option> + <option value="remote">{{ $t('remote') }}</option> + </MkSelect> + <MkInput v-model:value="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params().origin === 'local'"> + <span>{{ $t('host') }}</span> + </MkInput> + </div> + <div class="inputs" style="display: flex; padding-top: 1.2em;"> + <MkInput v-model:value="type" :debounce="true" type="search" style="margin: 0; flex: 1;"> + <span>{{ $t('type') }}</span> + </MkInput> + </div> + <MkPagination :pagination="pagination" #default="{items}" class="urempief" ref="files" :auto-margin="false"> + <button class="file _panel _button _vMargin" v-for="file in items" :key="file.id" @click="show(file, $event)"> + <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> + <div class="body"> + <div> + <small style="opacity: 0.7;">{{ file.name }}</small> + </div> + <div> + <MkAcct :user="file.user"/> + </div> + <div> + <span style="margin-right: 1em;">{{ file.type }}</span> + <span>{{ bytes(file.size) }}</span> + </div> + <div> + <span>{{ $t('registeredDate') }}: <MkTime :time="file.createdAt" mode="detail"/></span> + </div> + </div> + </button> + </MkPagination> + </div> + </div> +</div> </template> <script lang="ts"> -import Vue from 'vue'; -import { faCloud } from '@fortawesome/free-solid-svg-icons'; +import { defineComponent } from 'vue'; +import { faCloud, faSearch } 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')}` - }; - }, +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 MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; +export default defineComponent({ components: { MkButton, + MkInput, + MkSelect, MkPagination, + MkDriveFileThumbnail, }, data() { return { - faTrashAlt, faCloud + INFO: { + header: [{ + title: this.$t('files'), + icon: faCloud + }], + }, + q: null, + origin: 'local', + type: null, + searchHost: '', + pagination: { + endpoint: 'admin/drive/files', + limit: 10, + params: () => ({ + type: (this.type && this.type !== '') ? this.type : null, + origin: this.origin, + hostname: (this.hostname && this.hostname !== '') ? this.hostname : null, + }), + }, + faTrashAlt, faCloud, faSearch, } }, + watch: { + type() { + this.$refs.files.reload(); + }, + origin() { + this.$refs.files.reload(); + }, + searchHost() { + this.$refs.files.reload(); + }, + }, + methods: { clear() { - this.$root.dialog({ + os.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 + os.apiWithDialog('admin/drive/clean-remote-files', {}); + }); + }, + + async show(file, ev) { + os.popup(await import('./file-dialog.vue'), { + fileId: file.id + }, {}, 'closed'); + }, + + find() { + os.api('admin/drive/show-file', this.q.startsWith('http://') || this.q.startsWith('https://') ? { url: this.q.trim() } : { fileId: this.q.trim() }).then(file => { + this.show(file); + }).catch(e => { + if (e.code === 'NO_SUCH_FILE') { + os.dialog({ + type: 'error', + text: this.$t('notFound') }); - }); + } }); - } + }, + + bytes } }); </script> + +<style lang="scss" scoped> +.xrmjdkdw { + .urempief { + margin-top: var(--margin); + + > .file { + display: flex; + width: 100%; + box-sizing: border-box; + text-align: left; + align-items: center; + + &:hover { + color: var(--accent); + } + + > .thumbnail { + width: 128px; + height: 128px; + } + + > .body { + margin-left: 0.3em; + padding: 8px; + flex: 1; + + @media (max-width: 500px) { + font-size: 14px; + } + } + } + } +} +</style> diff --git a/src/client/pages/instance/index.metrics.vue b/src/client/pages/instance/index.metrics.vue new file mode 100644 index 0000000000..f3060b29d5 --- /dev/null +++ b/src/client/pages/instance/index.metrics.vue @@ -0,0 +1,576 @@ +<template> +<div> + <MkFolder> + <template #header><Fa :icon="faHeartbeat"/> {{ $t('metrics') }}</template> + <div class="_section" style="padding: 0 var(--margin);"> + <div class="_content"> + <MkContainer :body-togglable="false" class="_vMargin"> + <template #header><Fa :icon="faMicrochip"/>{{ $t('cpuAndMemory') }}</template> + <!-- + <template #func> + <button class="_button" @click="resume" :disabled="!paused"><Fa :icon="faPlay"/></button> + <button class="_button" @click="pause" :disabled="paused"><Fa :icon="faPause"/></button> + </template> + --> + + <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">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div> + <div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div> + <div class="_cell"><div class="_label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div> + </div> + </div> + </div> + </MkContainer> + + <MkContainer :body-togglable="false" class="_vMargin"> + <template #header><Fa :icon="faHdd"/> {{ $t('disk') }}</template> + <!-- + <template #func> + <button class="_button" @click="resume" :disabled="!paused"><Fa :icon="faPlay"/></button> + <button class="_button" @click="pause" :disabled="paused"><Fa :icon="faPause"/></button> + </template> + --> + + <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>{{ bytes(serverInfo.fs.total) }}</div> + <div class="_cell"><div class="_label">Disk used</div>{{ bytes(serverInfo.fs.used) }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div> + <div class="_cell"><div class="_label">Disk free</div>{{ bytes(serverInfo.fs.total - serverInfo.fs.used) }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div> + </div> + </div> + </div> + </MkContainer> + + <MkContainer :body-togglable="false" class="_vMargin"> + <template #header><Fa :icon="faExchangeAlt"/> {{ $t('network') }}</template> + <!-- + <template #func> + <button class="_button" @click="resume" :disabled="!paused"><Fa :icon="faPlay"/></button> + <button class="_button" @click="pause" :disabled="paused"><Fa :icon="faPause"/></button> + </template> + --> + + <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> + </MkContainer> + </div> + </div> + </MkFolder> + + <MkFolder> + <template #header><Fa :icon="faClipboardList"/> {{ $t('jobQueue') }}</template> + + <div class="vkyrmkwb" :style="{ gridTemplateRows: queueHeight }"> + <MkContainer :body-togglable="false" :scrollable="true" :resize-base-el="() => $el"> + <template #header><Fa :icon="faExclamationTriangle"/> {{ $t('delayed') }}</template> + + <div class="_content"> + <div class="_keyValue" v-for="job in jobs" :key="job[0]"> + <button class="_button" @click="showInstanceInfo(job[0])">{{ job[0] }}</button> + <div style="text-align: right;">{{ number(job[1]) }} jobs</div> + </div> + </div> + </MkContainer> + <XQueue :connection="queueConnection" domain="inbox" ref="queue" class="queue"> + <template #title><Fa :icon="faExchangeAlt"/> In</template> + </XQueue> + <XQueue :connection="queueConnection" domain="deliver" class="queue"> + <template #title><Fa :icon="faExchangeAlt"/> Out</template> + </XQueue> + </div> + </MkFolder> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import { faPlay, faPause, faDatabase, faServer, faExchangeAlt, faMicrochip, faHdd, faStream, faTrashAlt, faInfoCircle, faExclamationTriangle, faTachometerAlt, faHeartbeat, faClipboardList } from '@fortawesome/free-solid-svg-icons'; +import Chart from 'chart.js'; +import MkButton from '@/components/ui/button.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import MkwFederation from '../../widgets/federation.vue'; +import { version, url } from '@/config'; +import bytes from '../../filters/bytes'; +import number from '../../filters/number'; +import MkInstanceInfo from './instance.vue'; + +const alpha = (hex, a) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkSelect, + MkInput, + MkContainer, + MkFolder, + MkwFederation, + }, + + data() { + return { + version, + url, + stats: null, + serverInfo: null, + connection: null, + queueConnection: os.stream.useSharedConnection('queueStats'), + memUsage: 0, + chartCpuMem: null, + chartNet: null, + jobs: [], + logs: [], + logLevel: 'all', + logDomain: '', + modLogs: [], + dbInfo: null, + overviewHeight: '1fr', + queueHeight: '1fr', + paused: false, + faPlay, faPause, faDatabase, faServer, faExchangeAlt, faMicrochip, faHdd, faStream, faTrashAlt, faInfoCircle, faExclamationTriangle, faTachometerAlt, faHeartbeat, faClipboardList, + } + }, + + computed: { + gridColor() { + // TODO: var(--panel)の色が暗いか明るいかで判定する + return this.$store.state.device.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + }, + }, + + mounted() { + this.fetchJobs(); + + Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + os.api('admin/server-info', {}).then(res => { + this.serverInfo = res; + + this.connection = os.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 + }); + + this.$nextTick(() => { + this.queueConnection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 200 + }); + }); + }); + }, + + beforeUnmount() { + this.connection.off('stats', this.onStats); + this.connection.off('statsLog', this.onStatsLog); + this.connection.dispose(); + this.queueConnection.dispose(); + }, + + methods: { + cpumem(el) { + if (this.chartCpuMem != null) return; + this.chartCpuMem = markRaw(new Chart(el, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'CPU', + pointRadius: 0, + 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, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + } + }], + yAxes: [{ + position: 'right', + gridLines: { + display: true, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + max: 100 + } + }] + }, + tooltips: { + intersect: false, + mode: 'index', + } + } + })); + }, + + net(el) { + if (this.chartNet != null) return; + this.chartNet = markRaw(new Chart(el, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'In', + pointRadius: 0, + 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, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false + } + }], + yAxes: [{ + position: 'right', + gridLines: { + display: true, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + } + }] + }, + tooltips: { + intersect: false, + mode: 'index', + } + } + })); + }, + + disk(el) { + if (this.chartDisk != null) return; + this.chartDisk = markRaw(new Chart(el, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Read', + pointRadius: 0, + 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, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false + } + }], + yAxes: [{ + position: 'right', + gridLines: { + display: true, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + } + }] + }, + tooltips: { + intersect: false, + mode: 'index', + } + } + })); + }, + + async showInstanceInfo(q) { + let instance = q; + if (typeof q === 'string') { + instance = await os.api('federation/show-instance', { + host: q + }); + } + os.popup(MkInstanceInfo, { + instance: instance + }, {}, 'closed'); + }, + + fetchJobs() { + os.api('admin/queue/deliver-delayed', {}).then(jobs => { + this.jobs = jobs; + }); + }, + + onStats(stats) { + if (this.paused) return; + + const cpu = (stats.cpu * 100).toFixed(0); + const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0); + const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0); + this.memUsage = stats.mem.active; + + this.chartCpuMem.data.labels.push(''); + this.chartCpuMem.data.datasets[0].data.push(cpu); + this.chartCpuMem.data.datasets[1].data.push(memActive); + this.chartCpuMem.data.datasets[2].data.push(memUsed); + this.chartNet.data.labels.push(''); + this.chartNet.data.datasets[0].data.push(stats.net.rx); + this.chartNet.data.datasets[1].data.push(stats.net.tx); + this.chartDisk.data.labels.push(''); + this.chartDisk.data.datasets[0].data.push(stats.fs.r); + this.chartDisk.data.datasets[1].data.push(stats.fs.w); + if (this.chartCpuMem.data.datasets[0].data.length > 150) { + this.chartCpuMem.data.labels.shift(); + this.chartCpuMem.data.datasets[0].data.shift(); + this.chartCpuMem.data.datasets[1].data.shift(); + this.chartCpuMem.data.datasets[2].data.shift(); + this.chartNet.data.labels.shift(); + this.chartNet.data.datasets[0].data.shift(); + this.chartNet.data.datasets[1].data.shift(); + this.chartDisk.data.labels.shift(); + this.chartDisk.data.datasets[0].data.shift(); + this.chartDisk.data.datasets[1].data.shift(); + } + this.chartCpuMem.update(); + this.chartNet.update(); + this.chartDisk.update(); + }, + + onStatsLog(statsLog) { + for (const stats of [...statsLog].reverse()) { + this.onStats(stats); + } + }, + + bytes, + + number, + + pause() { + this.paused = true; + }, + + resume() { + this.paused = false; + }, + } +}); +</script> + +<style lang="scss" scoped> +.xhexznfu { + &.min-width_1000px { + .sboqnrfi { + display: grid; + grid-template-columns: 3.2fr 1fr; + grid-template-rows: 1fr; + gap: 16px 16px; + + > .stats { + height: min-content; + } + + > .column { + display: flex; + flex-direction: column; + + > .info { + flex-shrink: 0; + flex-grow: 0; + } + + > .db { + flex: 1; + flex-grow: 0; + height: 100%; + } + + > .fed { + flex: 1; + flex-grow: 0; + height: 100%; + } + + > *:not(:last-child) { + margin-bottom: var(--margin); + } + } + } + + .segusily { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 1fr; + gap: 16px 16px; + padding: 0 16px; + } + + .vkyrmkwb { + display: grid; + grid-template-columns: 0.5fr 1fr 1fr; + grid-template-rows: 1fr; + gap: 16px 16px; + margin-bottom: var(--margin); + + > .queue { + height: min-content; + } + + > * { + margin-bottom: 0; + } + } + + .uwuemslx { + display: grid; + grid-template-columns: 2fr 3fr; + grid-template-rows: 1fr; + gap: 16px 16px; + height: 400px; + } + } + + .vkyrmkwb { + > * { + margin-bottom: var(--margin); + } + } +} +</style> diff --git a/src/client/pages/instance/index.queue-chart.vue b/src/client/pages/instance/index.queue-chart.vue deleted file mode 100644 index 3b7823d924..0000000000 --- a/src/client/pages/instance/index.queue-chart.vue +++ /dev/null @@ -1,198 +0,0 @@ -<template> -<mk-container :body-togglable="false"> - <template #header><slot name="title"></slot></template> - <template #func><button class="_button" @click="resume" :disabled="!paused"><fa :icon="faPlay"/></button><button class="_button" @click="pause" :disabled="paused"><fa :icon="faPause"/></button></template> - - <div class="_content _table"> - <div class="_row"> - <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> - <div class="_content" style="margin-bottom: -8px;"> - <canvas ref="chart"></canvas> - </div> -</mk-container> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import Chart from 'chart.js'; -import { faPlay, faPause } from '@fortawesome/free-solid-svg-icons'; -import MkContainer from '../../components/ui/container.vue'; - -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({ - components: { - MkContainer, - }, - - props: { - domain: { - required: true - }, - connection: { - required: true - }, - }, - - data() { - return { - chart: null, - activeSincePrevTick: 0, - active: 0, - waiting: 0, - delayed: 0, - paused: false, - faPlay, faPause - } - }, - - mounted() { - // TODO: var(--panel)の色が暗いか明るいかで判定する - const gridColor = this.$store.state.device.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; - - Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - - this.chart = new Chart(this.$refs.chart, { - type: 'bar', - data: { - labels: [], - datasets: [{ - label: 'Process', - pointRadius: 0, - lineTension: 0, - borderWidth: 0, - backgroundColor: '#8BC34A', - data: [] - }, { - label: 'Active', - pointRadius: 0, - lineTension: 0, - borderWidth: 0, - backgroundColor: '#03A9F4', - data: [] - }, { - label: 'Waiting', - pointRadius: 0, - lineTension: 0, - borderWidth: 0, - backgroundColor: '#FFC107', - data: [] - }, { - label: 'Delayed', - order: -1, - type: 'line', - pointRadius: 0, - lineTension: 0, - borderWidth: 2, - borderColor: '#F44336', - 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: [{ - stacked: true, - gridLines: { - display: false, - color: gridColor, - zeroLineColor: gridColor, - }, - ticks: { - display: false - } - }], - yAxes: [{ - stacked: true, - position: 'right', - gridLines: { - display: true, - color: gridColor, - zeroLineColor: gridColor, - }, - 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) { - if (this.paused) return; - 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 > 100) { - 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); - } - }, - - pause() { - this.paused = true; - }, - - resume() { - this.paused = false; - }, - } -}); -</script> diff --git a/src/client/pages/instance/index.vue b/src/client/pages/instance/index.vue index f55a53b5f3..9383f256eb 100644 --- a/src/client/pages/instance/index.vue +++ b/src/client/pages/instance/index.vue @@ -1,219 +1,77 @@ <template> -<div v-if="meta" class="xhexznfu" v-size="{ min: [1600] }"> - <portal to="icon"><fa :icon="faServer"/></portal> - <portal to="title">{{ $t('instance') }}</portal> - - <mk-folder> - <template #header><fa :icon="faTachometerAlt"/> {{ $t('overview') }}</template> +<div v-if="meta" v-show="page === 'index'" class="xhexznfu _section"> + <MkFolder> + <template #header><Fa :icon="faTachometerAlt"/> {{ $t('overview') }}</template> <div class="sboqnrfi" :style="{ gridTemplateRows: overviewHeight }"> - <mk-instance-stats :chart-limit="300" :detailed="true" class="stats" ref="stats"/> - - <div class="column"> - <mk-container :body-togglable="true" :resize-base-el="() => $el" class="info"> - <template #header><fa :icon="faInfoCircle"/>{{ $t('instanceInfo') }}</template> - - <div class="_content"> - <div class="_keyValue"><b>Misskey</b><span>v{{ version }}</span></div> - </div> - <div class="_content" v-if="serverInfo"> - <div class="_keyValue"><b>Node.js</b><span>{{ serverInfo.node }}</span></div> - <div class="_keyValue"><b>PostgreSQL</b><span>v{{ serverInfo.psql }}</span></div> - <div class="_keyValue"><b>Redis</b><span>v{{ serverInfo.redis }}</span></div> - </div> - </mk-container> - - <mk-container :body-togglable="true" :scrollable="true" :resize-base-el="() => $el" class="db"> - <template #header><fa :icon="faDatabase"/>{{ $t('database') }}</template> - - <div class="_content" v-if="dbInfo"> - <table style="border-collapse: collapse; width: 100%;"> - <tr style="opacity: 0.7;"> - <th style="text-align: left; padding: 0 8px 8px 0;">Table</th> - <th style="text-align: left; padding: 0 8px 8px 0;">Records</th> - <th style="text-align: left; padding: 0 0 8px 0;">Size</th> - </tr> - <tr v-for="table in dbInfo" :key="table[0]"> - <th style="text-align: left; padding: 0 8px 0 0; word-break: break-all;">{{ table[0] }}</th> - <td style="padding: 0 8px 0 0;">{{ table[1].count | number }}</td> - <td style="padding: 0; opacity: 0.7;">{{ table[1].size | bytes }}</td> - </tr> - </table> - </div> - </mk-container> - - <mkw-federation class="fed" :body-togglable="true" :scrollable="true"/> - </div> - </div> - </mk-folder> - - <mk-folder style="margin: var(--margin) 0;"> - <template #header><fa :icon="faHeartbeat"/> {{ $t('metrics') }}</template> - - <div class="segusily"> - <mk-container :body-togglable="false" :resize-base-el="() => $el"> - <template #header><fa :icon="faMicrochip"/>{{ $t('cpuAndMemory') }}</template> - <template #func><button class="_button" @click="resume" :disabled="!paused"><fa :icon="faPlay"/></button><button class="_button" @click="pause" :disabled="paused"><fa :icon="faPause"/></button></template> - - <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> - </mk-container> - - <mk-container :body-togglable="false" :resize-base-el="() => $el"> - <template #header><fa :icon="faHdd"/> {{ $t('disk') }}</template> - <template #func><button class="_button" @click="resume" :disabled="!paused"><fa :icon="faPlay"/></button><button class="_button" @click="pause" :disabled="paused"><fa :icon="faPause"/></button></template> - - <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> - </mk-container> + <MkInstanceStats :chart-limit="300" :detailed="true" class="_vMargin" ref="stats"/> - <mk-container :body-togglable="false" :resize-base-el="() => $el"> - <template #header><fa :icon="faExchangeAlt"/> {{ $t('network') }}</template> - <template #func><button class="_button" @click="resume" :disabled="!paused"><fa :icon="faPlay"/></button><button class="_button" @click="pause" :disabled="paused"><fa :icon="faPause"/></button></template> + <MkContainer :body-togglable="true" class="_vMargin"> + <template #header><Fa :icon="faInfoCircle"/>{{ $t('instanceInfo') }}</template> - <div class="_content" style="margin-top: -8px; margin-bottom: -12px;"> - <canvas ref="net"></canvas> + <div class="_content"> + <div class="_keyValue"><b>Misskey</b><span>v{{ version }}</span></div> </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 class="_keyValue"><b>Node.js</b><span>{{ serverInfo.node }}</span></div> + <div class="_keyValue"><b>PostgreSQL</b><span>v{{ serverInfo.psql }}</span></div> + <div class="_keyValue"><b>Redis</b><span>v{{ serverInfo.redis }}</span></div> </div> - </mk-container> - </div> - </mk-folder> - - <mk-folder> - <template #header><fa :icon="faClipboardList"/> {{ $t('jobQueue') }}</template> - - <div class="vkyrmkwb" :style="{ gridTemplateRows: queueHeight }"> - <mk-container :body-togglable="false" :scrollable="true" :resize-base-el="() => $el"> - <template #header><fa :icon="faExclamationTriangle"/> {{ $t('delayed') }}</template> + </MkContainer> + + <MkContainer :body-togglable="true" :scrollable="true" class="_vMargin" style="height: 300px;"> + <template #header><Fa :icon="faDatabase"/>{{ $t('database') }}</template> - <div class="_content"> - <div class="_keyValue" v-for="job in jobs" :key="job[0]"> - <button class="_button" @click="showInstanceInfo(job[0])">{{ job[0] }}</button> - <div style="text-align: right;">{{ job[1] | number }} jobs</div> - </div> + <div class="_content" v-if="dbInfo"> + <table style="border-collapse: collapse; width: 100%;"> + <tr style="opacity: 0.7;"> + <th style="text-align: left; padding: 0 8px 8px 0;">Table</th> + <th style="text-align: left; padding: 0 8px 8px 0;">Records</th> + <th style="text-align: left; padding: 0 0 8px 0;">Size</th> + </tr> + <tr v-for="table in dbInfo" :key="table[0]"> + <th style="text-align: left; padding: 0 8px 0 0; word-break: break-all;">{{ table[0] }}</th> + <td style="padding: 0 8px 0 0;">{{ number(table[1].count) }}</td> + <td style="padding: 0; opacity: 0.7;">{{ bytes(table[1].size) }}</td> + </tr> + </table> </div> - </mk-container> - <x-queue :connection="queueConnection" domain="inbox" ref="queue" class="queue"> - <template #title><fa :icon="faExchangeAlt"/> In</template> - </x-queue> - <x-queue :connection="queueConnection" domain="deliver" class="queue"> - <template #title><fa :icon="faExchangeAlt"/> Out</template> - </x-queue> + </MkContainer> </div> - </mk-folder> - - <mk-folder> - <template #header><fa :icon="faStream"/> {{ $t('logs') }}</template> - - <div class="uwuemslx"> - <mk-container :body-togglable="false" :resize-base-el="() => $el"> - <template #header><fa :icon="faInfoCircle"/>{{ $t('') }}</template> - - <div class="_content"> - <div class="_keyValue" v-for="log in modLogs"> - <b>{{ log.type }}</b><span>by {{ log.user.username }}</span><mk-time :time="log.createdAt" style="opacity: 0.7;"/> - </div> - </div> - </mk-container> - - <section class="_card logs"> - <div class="_title"><fa :icon="faStream"/> {{ $t('serverLogs') }}</div> - <div class="_content"> - <div class="_inputs"> - <mk-input v-model="logDomain" :debounce="true"> - <span>{{ $t('domain') }}</span> - </mk-input> - <mk-select v-model="logLevel"> - <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> - </mk-select> - </div> + </MkFolder> +</div> +<div v-if="page === 'logs'" class="_section"> + <MkFolder> + <template #header><Fa :icon="faStream"/> {{ $t('logs') }}</template> - <div class="logs"> - <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> - </div> - <div class="_footer"> - <mk-button @click="deleteAllLogs()" primary><fa :icon="faTrashAlt"/> {{ $t('deleteAll') }}</mk-button> - </div> - </section> + <div class="_keyValue" v-for="log in modLogs"> + <b>{{ log.type }}</b><span>by {{ log.user.username }}</span><MkTime :time="log.createdAt" style="opacity: 0.7;"/> </div> - </mk-folder> + </MkFolder> +</div> +<div v-if="page === 'metrics'"> + <XMetrics/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { computed, defineComponent, markRaw } from 'vue'; import { faPlay, faPause, faDatabase, faServer, faExchangeAlt, faMicrochip, faHdd, faStream, faTrashAlt, faInfoCircle, faExclamationTriangle, faTachometerAlt, faHeartbeat, faClipboardList } from '@fortawesome/free-solid-svg-icons'; -import Chart from 'chart.js'; import VueJsonPretty from 'vue-json-pretty'; -import MkInstanceStats from '../../components/instance-stats.vue'; -import MkButton from '../../components/ui/button.vue'; -import MkSelect from '../../components/ui/select.vue'; -import MkInput from '../../components/ui/input.vue'; -import MkContainer from '../../components/ui/container.vue'; -import MkFolder from '../../components/ui/folder.vue'; -import MkwFederation from '../../widgets/federation.vue'; -import { version, url } from '../../config'; -import XQueue from './index.queue-chart.vue'; +import MkInstanceStats from '@/components/instance-stats.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import { version, url } from '@/config'; +import bytes from '../../filters/bytes'; +import number from '../../filters/number'; import MkInstanceInfo from './instance.vue'; +import XMetrics from './index.metrics.vue'; +import * as os from '@/os'; -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({ - metaInfo() { - return { - title: this.$t('instance') as string - }; - }, - +export default defineComponent({ components: { MkInstanceStats, MkButton, @@ -221,31 +79,43 @@ export default Vue.extend({ MkInput, MkContainer, MkFolder, - MkwFederation, - XQueue, + XMetrics, VueJsonPretty, }, data() { return { + INFO: { + header: [{ + id: 'index', + title: null, + tooltip: this.$t('instance'), + icon: faServer, + onClick: () => { this.page = 'index'; }, + selected: computed(() => this.page === 'index') + }, { + id: 'metrics', + title: null, + tooltip: this.$t('metrics'), + icon: faHeartbeat, + onClick: () => { this.page = 'metrics'; }, + selected: computed(() => this.page === 'metrics') + }, { + id: 'logs', + title: null, + tooltip: this.$t('logs'), + icon: faStream, + onClick: () => { this.page = 'logs'; }, + selected: computed(() => this.page === 'logs') + }] + }, + page: 'index', version, url, stats: null, serverInfo: null, - connection: null, - queueConnection: this.$root.stream.useSharedConnection('queueStats'), - memUsage: 0, - chartCpuMem: null, - chartNet: null, - jobs: [], - logs: [], - logLevel: 'all', - logDomain: '', modLogs: [], dbInfo: null, - overviewHeight: '1fr', - queueHeight: '1fr', - paused: false, faPlay, faPause, faDatabase, faServer, faExchangeAlt, faMicrochip, faHdd, faStream, faTrashAlt, faInfoCircle, faExclamationTriangle, faTachometerAlt, faHeartbeat, faClipboardList, } }, @@ -256,509 +126,47 @@ export default Vue.extend({ }, }, - watch: { - logLevel() { - this.logs = []; - this.fetchLogs(); - }, - logDomain() { - this.logs = []; - this.fetchLogs(); - } - }, - - created() { - this.$store.commit('setFullView', true); - }, - mounted() { - this.fetchLogs(); this.fetchJobs(); this.fetchModLogs(); - // TODO: var(--panel)の色が暗いか明るいかで判定する - const gridColor = this.$store.state.device.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; - - 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, - color: gridColor, - zeroLineColor: gridColor, - }, - ticks: { - display: false, - } - }], - yAxes: [{ - position: 'right', - gridLines: { - display: true, - color: gridColor, - zeroLineColor: gridColor, - }, - 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, - color: gridColor, - zeroLineColor: gridColor, - }, - ticks: { - display: false - } - }], - yAxes: [{ - position: 'right', - gridLines: { - display: true, - color: gridColor, - zeroLineColor: gridColor, - }, - 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, - color: gridColor, - zeroLineColor: gridColor, - }, - ticks: { - display: false - } - }], - yAxes: [{ - position: 'right', - gridLines: { - display: true, - color: gridColor, - zeroLineColor: gridColor, - }, - ticks: { - display: false, - } - }] - }, - tooltips: { - intersect: false, - mode: 'index', - } - } - }); - - this.$root.api('admin/server-info', {}).then(res => { + os.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 - }); - - this.$nextTick(() => { - this.queueConnection.send('requestLog', { - id: Math.random().toString().substr(2, 8), - length: 200 - }); - }); }); - this.$root.api('admin/get-table-stats', {}).then(res => { + os.api('admin/get-table-stats', {}).then(res => { this.dbInfo = Object.entries(res).sort((a, b) => b[1].size - a[1].size); }); - - this.$nextTick(() => { - new ResizeObserver((entries, observer) => { - if (this.$refs.stats && this.$refs.stats.$el) { - this.overviewHeight = this.$refs.stats.$el.offsetHeight + 'px'; - } - }).observe(this.$refs.stats.$el); - - new ResizeObserver((entries, observer) => { - if (this.$refs.queue && this.$refs.queue.$el) { - this.queueHeight = this.$refs.queue.$el.offsetHeight + 'px'; - } - }).observe(this.$refs.queue.$el); - }); - }, - - beforeDestroy() { - this.connection.off('stats', this.onStats); - this.connection.off('statsLog', this.onStatsLog); - this.connection.dispose(); - this.queueConnection.dispose(); - this.$store.commit('setFullView', false); }, methods: { async showInstanceInfo(q) { let instance = q; if (typeof q === 'string') { - instance = await this.$root.api('federation/show-instance', { + instance = await os.api('federation/show-instance', { host: q }); } - this.$root.new(MkInstanceInfo, { + os.popup(MkInstanceInfo, { instance: instance - }); - }, - - fetchLogs() { - this.$root.api('admin/logs', { - level: this.logLevel === 'all' ? null : this.logLevel, - domain: this.logDomain === '' ? null : this.logDomain, - limit: 30 - }).then(logs => { - this.logs = logs.reverse(); - }); + }, {}, 'closed'); }, fetchJobs() { - this.$root.api('admin/queue/deliver-delayed', {}).then(jobs => { + os.api('admin/queue/deliver-delayed', {}).then(jobs => { this.jobs = jobs; }); }, fetchModLogs() { - this.$root.api('admin/show-moderation-logs', {}).then(logs => { + os.api('admin/show-moderation-logs', {}).then(logs => { this.modLogs = logs; }); }, - deleteAllLogs() { - this.$root.api('admin/delete-logs').then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }); - }, - - onStats(stats) { - if (this.paused) return; - - const cpu = (stats.cpu * 100).toFixed(0); - const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0); - const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0); - this.memUsage = stats.mem.active; - - this.chartCpuMem.data.labels.push(''); - this.chartCpuMem.data.datasets[0].data.push(cpu); - this.chartCpuMem.data.datasets[1].data.push(memActive); - this.chartCpuMem.data.datasets[2].data.push(memUsed); - this.chartNet.data.labels.push(''); - this.chartNet.data.datasets[0].data.push(stats.net.rx); - this.chartNet.data.datasets[1].data.push(stats.net.tx); - this.chartDisk.data.labels.push(''); - this.chartDisk.data.datasets[0].data.push(stats.fs.r); - this.chartDisk.data.datasets[1].data.push(stats.fs.w); - if (this.chartCpuMem.data.datasets[0].data.length > 150) { - this.chartCpuMem.data.labels.shift(); - this.chartCpuMem.data.datasets[0].data.shift(); - this.chartCpuMem.data.datasets[1].data.shift(); - this.chartCpuMem.data.datasets[2].data.shift(); - this.chartNet.data.labels.shift(); - this.chartNet.data.datasets[0].data.shift(); - this.chartNet.data.datasets[1].data.shift(); - this.chartDisk.data.labels.shift(); - this.chartDisk.data.datasets[0].data.shift(); - this.chartDisk.data.datasets[1].data.shift(); - } - this.chartCpuMem.update(); - this.chartNet.update(); - this.chartDisk.update(); - }, - - onStatsLog(statsLog) { - for (const stats of [...statsLog].reverse()) { - this.onStats(stats); - } - }, - - pause() { - this.paused = true; - }, + bytes, - resume() { - this.paused = false; - }, + number, } }); </script> - -<style lang="scss" scoped> -.xhexznfu { - &.min-width_1600px { - .sboqnrfi { - display: grid; - grid-template-columns: 3.2fr 1fr; - grid-template-rows: 1fr; - gap: 16px 16px; - - > .stats { - height: min-content; - } - - > .column { - display: flex; - flex-direction: column; - - > .info { - flex-shrink: 0; - flex-grow: 0; - } - - > .db { - flex: 1; - flex-grow: 0; - height: 100%; - } - - > .fed { - flex: 1; - flex-grow: 0; - height: 100%; - } - - > *:not(:last-child) { - margin-bottom: var(--margin); - } - } - } - - .segusily { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - grid-template-rows: 1fr; - gap: 16px 16px; - } - - .vkyrmkwb { - display: grid; - grid-template-columns: 0.5fr 1fr 1fr; - grid-template-rows: 1fr; - gap: 16px 16px; - margin-bottom: var(--margin); - - > .queue { - height: min-content; - } - - > * { - margin-bottom: 0; - } - } - - .uwuemslx { - display: grid; - grid-template-columns: 2fr 3fr; - grid-template-rows: 1fr; - gap: 16px 16px; - height: 400px; - } - } - - .vkyrmkwb { - > * { - margin-bottom: var(--margin); - } - } - - > .stats { - display: flex; - justify-content: space-between; - flex-wrap: wrap; - margin: calc(0px - var(--margin) / 2); - margin-bottom: calc(var(--margin) / 2); - - > div { - flex: 1 0 213px; - margin: calc(var(--margin) / 2); - box-sizing: border-box; - padding: 16px; - } - } - - > .logs { - > ._content { - > .logs { - padding: 8px; - background: #000; - color: #fff; - font-size: 0.9em; - - > code { - display: block; - - &.error { - color: #f00; - } - - &.warning { - color: #ff0; - } - - &.success { - color: #0f0; - } - - &.debug { - opacity: 0.7; - } - } - } - } - } -} -</style> diff --git a/src/client/pages/instance/instance.vue b/src/client/pages/instance/instance.vue index 30893f381b..97f85d3b1f 100644 --- a/src/client/pages/instance/instance.vue +++ b/src/client/pages/instance/instance.vue @@ -1,8 +1,13 @@ <template> -<x-window @closed="() => { $emit('closed'); destroyDom(); }" :no-padding="true" :width="520" :height="500"> +<XModalWindow ref="dialog" + :width="520" + :height="500" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> <template #header>{{ instance.host }}</template> <div class="mk-instance-info"> - <div class="_table"> + <div class="_table section"> <div class="_row"> <div class="_cell"> <div class="_label">{{ $t('software') }}</div> @@ -14,47 +19,47 @@ </div> </div> </div> - <div class="_table data"> + <div class="_table data section"> <div class="_row"> <div class="_cell"> <div class="_label">{{ $t('registeredAt') }}</div> - <div class="_data">{{ new Date(instance.caughtAt).toLocaleString() }} (<mk-time :time="instance.caughtAt"/>)</div> + <div class="_data">{{ new Date(instance.caughtAt).toLocaleString() }} (<MkTime :time="instance.caughtAt"/>)</div> </div> </div> <div class="_row"> <div class="_cell"> <div class="_label">{{ $t('following') }}</div> - <button class="_data _textButton" @click="showFollowing()">{{ instance.followingCount | number }}</button> + <button class="_data _textButton" @click="showFollowing()">{{ number(instance.followingCount) }}</button> </div> <div class="_cell"> <div class="_label">{{ $t('followers') }}</div> - <button class="_data _textButton" @click="showFollowers()">{{ instance.followersCount | number }}</button> + <button class="_data _textButton" @click="showFollowers()">{{ number(instance.followersCount) }}</button> </div> </div> <div class="_row"> <div class="_cell"> <div class="_label">{{ $t('users') }}</div> - <button class="_data _textButton" @click="showUsers()">{{ instance.usersCount | number }}</button> + <button class="_data _textButton" @click="showUsers()">{{ number(instance.usersCount) }}</button> </div> <div class="_cell"> <div class="_label">{{ $t('notes') }}</div> - <div class="_data">{{ instance.notesCount | number }}</div> + <div class="_data">{{ number(instance.notesCount) }}</div> </div> </div> <div class="_row"> <div class="_cell"> <div class="_label">{{ $t('files') }}</div> - <div class="_data">{{ instance.driveFiles | number }}</div> + <div class="_data">{{ number(instance.driveFiles) }}</div> </div> <div class="_cell"> <div class="_label">{{ $t('storageUsage') }}</div> - <div class="_data">{{ instance.driveUsage | bytes }}</div> + <div class="_data">{{ bytes(instance.driveUsage) }}</div> </div> </div> <div class="_row"> <div class="_cell"> <div class="_label">{{ $t('latestRequestSentAt') }}</div> - <div class="_data"><mk-time v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div> + <div class="_data"><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div> </div> <div class="_cell"> <div class="_label">{{ $t('latestStatus') }}</div> @@ -64,7 +69,7 @@ <div class="_row"> <div class="_cell"> <div class="_label">{{ $t('latestRequestReceivedAt') }}</div> - <div class="_data"><mk-time v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div> + <div class="_data"><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div> </div> </div> </div> @@ -72,7 +77,7 @@ <div class="header"> <span class="label">{{ $t('charts') }}</span> <div class="selects"> - <mk-select v-model="chartSrc" style="margin: 0; flex: 1;"> + <MkSelect v-model:value="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> @@ -84,49 +89,52 @@ <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;"> + </MkSelect> + <MkSelect v-model:value="chartSpan" style="margin: 0;"> <option value="hour">{{ $t('perHour') }}</option> <option value="day">{{ $t('perDay') }}</option> - </mk-select> + </MkSelect> </div> </div> <div class="chart"> - <canvas ref="chart"></canvas> + <canvas :ref="setChart"></canvas> </div> </div> - <div class="operations"> + <div class="operations section"> <span class="label">{{ $t('operations') }}</span> - <mk-switch v-model="isSuspended" class="switch">{{ $t('stopActivityDelivery') }}</mk-switch> - <mk-switch :value="isBlocked" class="switch" @change="changeBlock">{{ $t('blockThisInstance') }}</mk-switch> + <MkSwitch v-model:value="isSuspended" class="switch">{{ $t('stopActivityDelivery') }}</MkSwitch> + <MkSwitch :value="isBlocked" class="switch" @update:value="changeBlock">{{ $t('blockThisInstance') }}</MkSwitch> <details> <summary>{{ $t('deleteAllFiles') }}</summary> - <mk-button @click="deleteAllFiles()" style="margin: 0.5em 0 0.5em 0;"><fa :icon="faTrashAlt"/> {{ $t('deleteAllFiles') }}</mk-button> + <MkButton @click="deleteAllFiles()" style="margin: 0.5em 0 0.5em 0;"><Fa :icon="faTrashAlt"/> {{ $t('deleteAllFiles') }}</MkButton> </details> <details> <summary>{{ $t('removeAllFollowing') }}</summary> - <mk-button @click="removeAllFollowing()" style="margin: 0.5em 0 0.5em 0;"><fa :icon="faMinusCircle"/> {{ $t('removeAllFollowing') }}</mk-button> - <mk-info warn>{{ $t('removeAllFollowingDescription', { host: instance.host }) }}</mk-info> + <MkButton @click="removeAllFollowing()" style="margin: 0.5em 0 0.5em 0;"><Fa :icon="faMinusCircle"/> {{ $t('removeAllFollowing') }}</MkButton> + <MkInfo warn>{{ $t('removeAllFollowingDescription', { host: instance.host }) }}</MkInfo> </details> </div> - <details class="metadata"> + <details class="metadata section"> <summary class="label">{{ $t('metadata') }}</summary> <pre><code>{{ JSON.stringify(instance, null, 2) }}</code></pre> </details> </div> -</x-window> +</XModalWindow> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import Chart from 'chart.js'; import { faTimes, faCrosshairs, faCloudDownloadAlt, faCloudUploadAlt, faUsers, faPencilAlt, faFileImage, faDatabase, faTrafficLight, faLongArrowAltUp, faLongArrowAltDown, faMinusCircle, faTrashAlt } 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 MkButton from '../../components/ui/button.vue'; -import MkSwitch from '../../components/ui/switch.vue'; -import MkInfo from '../../components/ui/info.vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkUsersDialog from '@/components/users-dialog.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import MkInfo from '@/components/ui/info.vue'; +import bytes from '../../filters/bytes'; +import number from '../../filters/number'; +import * as os from '@/os'; const chartLimit = 90; const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); @@ -139,9 +147,9 @@ const alpha = hex => { return `rgba(${r}, ${g}, ${b}, 0.1)`; }; -export default Vue.extend({ +export default defineComponent({ components: { - XWindow, + XModalWindow, MkSelect, MkButton, MkSwitch, @@ -155,10 +163,13 @@ export default Vue.extend({ } }, + emits: ['closed'], + data() { return { isSuspended: this.instance.isSuspended, now: null, + canvas: null, chart: null, chartInstance: null, chartSrc: 'requests', @@ -199,13 +210,13 @@ export default Vue.extend({ }, isBlocked() { - return this.meta && this.meta.blockedHosts.includes(this.instance.host); + return this.meta && this.meta.blockedHosts && this.meta.blockedHosts.includes(this.instance.host); } }, watch: { isSuspended() { - this.$root.api('admin/federation/update-instance', { + os.api('admin/federation/update-instance', { host: this.instance.host, isSuspended: this.isSuspended }); @@ -220,12 +231,12 @@ export default Vue.extend({ } }, - async created() { + async created() { 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' }), + os.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'hour' }), + os.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'day' }), ]); const chart = { @@ -239,8 +250,12 @@ export default Vue.extend({ }, methods: { + setChart(el) { + this.canvas = el; + }, + changeBlock(e) { - this.$root.api('admin/update-meta', { + os.api('admin/update-meta', { blockedHosts: this.isBlocked ? this.meta.blockedHosts.concat([this.instance.host]) : this.meta.blockedHosts.filter(x => x !== this.instance.host) }); }, @@ -250,24 +265,14 @@ export default Vue.extend({ }, removeAllFollowing() { - this.$root.api('admin/federation/remove-all-following', { + os.apiWithDialog('admin/federation/remove-all-following', { host: this.instance.host - }).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); }); }, deleteAllFiles() { - this.$root.api('admin/federation/delete-all-files', { + os.apiWithDialog('admin/federation/delete-all-files', { host: this.instance.host - }).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); }); }, @@ -277,7 +282,7 @@ export default Vue.extend({ } Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - this.chartInstance = new Chart(this.$refs.chart, { + this.chartInstance = new Chart(this.canvas, { type: 'line', data: { labels: new Array(chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(), @@ -436,7 +441,7 @@ export default Vue.extend({ }, showFollowing() { - this.$root.new(MkUsersDialog, { + os.modal(MkUsersDialog, { title: this.$t('instanceFollowing'), pagination: { endpoint: 'federation/following', @@ -450,7 +455,7 @@ export default Vue.extend({ }, showFollowers() { - this.$root.new(MkUsersDialog, { + os.modal(MkUsersDialog, { title: this.$t('instanceFollowers'), pagination: { endpoint: 'federation/followers', @@ -464,7 +469,7 @@ export default Vue.extend({ }, showUsers() { - this.$root.new(MkUsersDialog, { + os.modal(MkUsersDialog, { title: this.$t('instanceUsers'), pagination: { endpoint: 'federation/users', @@ -474,7 +479,11 @@ export default Vue.extend({ } } }); - } + }, + + bytes, + + number } }); </script> @@ -483,34 +492,21 @@ export default Vue.extend({ .mk-instance-info { overflow: auto; - > ._table { - padding: 0 32px; + > .section { + padding: 16px 32px; @media (max-width: 500px) { - padding: 0 16px; + padding: 8px 16px; } - } - - > .data { - margin-top: 16px; - padding-top: 16px; - border-top: solid 1px var(--divider); - @media (max-width: 500px) { - margin-top: 8px; - padding-top: 8px; + &:not(:first-child) { + border-top: solid 1px var(--divider); } } > .chart { - margin-top: 16px; - padding-top: 16px; border-top: solid 1px var(--divider); - - @media (max-width: 500px) { - margin-top: 8px; - padding-top: 8px; - } + padding: 16px 0 12px 0; > .header { padding: 0 32px; @@ -539,15 +535,6 @@ export default Vue.extend({ } > .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; @@ -559,13 +546,6 @@ export default Vue.extend({ } > .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; diff --git a/src/client/pages/instance/logs.vue b/src/client/pages/instance/logs.vue new file mode 100644 index 0000000000..5549bd5a1a --- /dev/null +++ b/src/client/pages/instance/logs.vue @@ -0,0 +1,95 @@ +<template> +<div class="_section"> + <div class="_inputs"> + <MkInput v-model:value="logDomain" :debounce="true"> + <span>{{ $t('domain') }}</span> + </MkInput> + <MkSelect v-model:value="logLevel"> + <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> + </MkSelect> + </div> + + <div class="logs"> + <code v-for="log in logs" :key="log.id" :class="log.level"> + <details> + <summary><MkTime :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> + + <MkButton @click="deleteAllLogs()" primary><Fa :icon="faTrashAlt"/> {{ $t('deleteAll') }}</MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faStream } from '@fortawesome/free-solid-svg-icons'; +import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSelect, + MkTextarea, + }, + + data() { + return { + INFO: { + header: [{ + title: this.$t('serverLogs'), + icon: faStream + }] + }, + logs: [], + logLevel: 'all', + logDomain: '', + faTrashAlt, + } + }, + + watch: { + logLevel() { + this.logs = []; + this.fetchLogs(); + }, + logDomain() { + this.logs = []; + this.fetchLogs(); + } + }, + + created() { + this.fetchLogs(); + }, + + methods: { + fetchLogs() { + os.api('admin/logs', { + level: this.logLevel === 'all' ? null : this.logLevel, + domain: this.logDomain === '' ? null : this.logDomain, + limit: 30 + }).then(logs => { + this.logs = logs.reverse(); + }); + }, + + deleteAllLogs() { + os.apiWithDialog('admin/delete-logs'); + }, + } +}); +</script> diff --git a/src/client/pages/instance/queue.chart.vue b/src/client/pages/instance/queue.chart.vue index 8f66c8e486..742c2b7d3c 100644 --- a/src/client/pages/instance/queue.chart.vue +++ b/src/client/pages/instance/queue.chart.vue @@ -1,12 +1,12 @@ <template> -<section class="_card"> +<section class="_section"> <div class="_title"><slot name="title"></slot></div> <div class="_content _table"> <div class="_row"> - <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 class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> + <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div> + <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div> + <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div> </div> </div> <div class="_content" style="margin-bottom: -8px;"> @@ -16,7 +16,7 @@ <div v-if="jobs.length > 0"> <div v-for="job in jobs" :key="job[0]"> <span>{{ job[0] }}</span> - <span style="margin-left: 8px; opacity: 0.7;">({{ job[1] | number }} jobs)</span> + <span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span> </div> </div> <span v-else style="opacity: 0.5;">{{ $t('noJobs') }}</span> @@ -25,8 +25,9 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import Chart from 'chart.js'; +import number from '../../filters/number'; const alpha = (hex, a) => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; @@ -35,8 +36,9 @@ const alpha = (hex, a) => { const b = parseInt(result[3], 16); return `rgba(${r}, ${g}, ${b}, ${a})`; }; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { domain: { required: true @@ -154,7 +156,7 @@ export default Vue.extend({ this.connection.on('statsLog', this.onStatsLog); }, - beforeDestroy() { + beforeUnmount() { this.connection.off('stats', this.onStats); this.connection.off('statsLog', this.onStatsLog); }, @@ -187,10 +189,12 @@ export default Vue.extend({ }, fetchJobs() { - this.$root.api(this.domain === 'inbox' ? 'admin/queue/inbox-delayed' : this.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(jobs => { + os.api(this.domain === 'inbox' ? 'admin/queue/inbox-delayed' : this.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(jobs => { this.jobs = jobs; }); }, + + number } }); </script> diff --git a/src/client/pages/instance/queue.vue b/src/client/pages/instance/queue.vue index d9f12577e4..5dec95c670 100644 --- a/src/client/pages/instance/queue.vue +++ b/src/client/pages/instance/queue.vue @@ -1,36 +1,28 @@ <template> <div> - <portal to="icon"><fa :icon="faExchangeAlt"/></portal> - <portal to="title">{{ $t('jobQueue') }}</portal> - - <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="_card"> + <XQueue :connection="connection" domain="inbox"> + <template #title><Fa :icon="faExchangeAlt"/> In</template> + </XQueue> + <XQueue :connection="connection" domain="deliver"> + <template #title><Fa :icon="faExchangeAlt"/> Out</template> + </XQueue> + <section class="_section"> <div class="_content"> - <mk-button @click="clear()"><fa :icon="faTrashAlt"/> {{ $t('clearQueue') }}</mk-button> + <MkButton @click="clear()"><Fa :icon="faTrashAlt"/> {{ $t('clearQueue') }}</MkButton> </div> </section> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faExchangeAlt } from '@fortawesome/free-solid-svg-icons'; import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; -import MkButton from '../../components/ui/button.vue'; +import MkButton from '@/components/ui/button.vue'; import XQueue from './queue.chart.vue'; +import * as os from '@/os'; -export default Vue.extend({ - metaInfo() { - return { - title: `${this.$t('jobQueue')} | ${this.$t('instance')}` - }; - }, - +export default defineComponent({ components: { MkButton, XQueue, @@ -38,7 +30,13 @@ export default Vue.extend({ data() { return { - connection: this.$root.stream.useSharedConnection('queueStats'), + INFO: { + header: [{ + title: this.$t('jobQueue'), + icon: faExchangeAlt, + }], + }, + connection: os.stream.useSharedConnection('queueStats'), faExchangeAlt, faTrashAlt } }, @@ -52,13 +50,13 @@ export default Vue.extend({ }); }, - beforeDestroy() { + beforeUnmount() { this.connection.dispose(); }, methods: { clear() { - this.$root.dialog({ + os.dialog({ type: 'warning', title: this.$t('clearQueueConfirmTitle'), text: this.$t('clearQueueConfirmText'), @@ -66,12 +64,7 @@ export default Vue.extend({ }).then(({ canceled }) => { if (canceled) return; - this.$root.api('admin/queue/clear', {}).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }); + os.apiWithDialog('admin/queue/clear', {}); }); } } diff --git a/src/client/pages/instance/relays.vue b/src/client/pages/instance/relays.vue index eaf6c0b682..82b7b006ed 100644 --- a/src/client/pages/instance/relays.vue +++ b/src/client/pages/instance/relays.vue @@ -1,43 +1,35 @@ <template> <div class="relaycxt"> - <portal to="icon"><fa :icon="faProjectDiagram"/></portal> - <portal to="title">{{ $t('relays') }}</portal> - - <section class="_card _vMargin add"> - <div class="_title"><fa :icon="faPlus"/> {{ $t('addRelay') }}</div> + <section class="_section add"> + <div class="_title"><Fa :icon="faPlus"/> {{ $t('addRelay') }}</div> <div class="_content"> - <mk-input v-model="inbox"> + <MkInput v-model:value="inbox"> <span>{{ $t('inboxUrl') }}</span> - </mk-input> - <mk-button @click="add(inbox)" primary><fa :icon="faPlus"/> {{ $t('add') }}</mk-button> + </MkInput> + <MkButton @click="add(inbox)" primary><Fa :icon="faPlus"/> {{ $t('add') }}</MkButton> </div> </section> - <section class="_card _vMargin relays"> - <div class="_title"><fa :icon="faProjectDiagram"/> {{ $t('addedRelays') }}</div> + <section class="_section relays"> + <div class="_title"><Fa :icon="faProjectDiagram"/> {{ $t('addedRelays') }}</div> <div class="_content relay" v-for="relay in relays" :key="relay.inbox"> <div>{{ relay.inbox }}</div> <div>{{ $t(`_relayStatus.${relay.status}`) }}</div> - <mk-button class="button" inline @click="remove(relay.inbox)"><fa :icon="faTrashAlt"/> {{ $t('remove') }}</mk-button> + <MkButton class="button" inline @click="remove(relay.inbox)"><Fa :icon="faTrashAlt"/> {{ $t('remove') }}</MkButton> </div> </section> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faPlus, faProjectDiagram } from '@fortawesome/free-solid-svg-icons'; import { faSave, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; -import MkButton from '../../components/ui/button.vue'; -import MkInput from '../../components/ui/input.vue'; - -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('relays') as string - }; - }, +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import * as os from '@/os'; +export default defineComponent({ components: { MkButton, MkInput, @@ -45,6 +37,12 @@ export default Vue.extend({ data() { return { + INFO: { + header: [{ + title: this.$t('relays'), + icon: faProjectDiagram, + }], + }, relays: [], inbox: '', faPlus, faProjectDiagram, faSave, faTrashAlt @@ -57,12 +55,12 @@ export default Vue.extend({ methods: { add(inbox: string) { - this.$root.api('admin/relays/add', { + os.api('admin/relays/add', { inbox }).then((relay: any) => { this.refresh(); }).catch((e: any) => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e.message || e }); @@ -70,12 +68,12 @@ export default Vue.extend({ }, remove(inbox: string) { - this.$root.api('admin/relays/remove', { + os.api('admin/relays/remove', { inbox }).then(() => { this.refresh(); }).catch((e: any) => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e.message || e }); @@ -83,7 +81,7 @@ export default Vue.extend({ }, refresh() { - this.$root.api('admin/relays/list').then((relays: any) => { + os.api('admin/relays/list').then((relays: any) => { this.relays = relays; }); } diff --git a/src/client/pages/instance/settings.vue b/src/client/pages/instance/settings.vue index 0c0e506ab8..e8bf4a0bda 100644 --- a/src/client/pages/instance/settings.vue +++ b/src/client/pages/instance/settings.vue @@ -1,53 +1,50 @@ <template> <div v-if="meta"> - <portal to="icon"><fa :icon="faCog"/></portal> - <portal to="title">{{ $t('settings') }}</portal> - - <section class="_card _vMargin info"> - <div class="_title"><fa :icon="faInfoCircle"/> {{ $t('basicInfo') }}</div> + <section class="_section info"> + <div class="_title"><Fa :icon="faInfoCircle"/> {{ $t('basicInfo') }}</div> <div class="_content"> - <mk-input v-model="name">{{ $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> + <MkInput v-model:value="name">{{ $t('instanceName') }}</MkInput> + <MkTextarea v-model:value="description">{{ $t('instanceDescription') }}</MkTextarea> + <MkInput v-model:value="iconUrl"><template #icon><Fa :icon="faLink"/></template>{{ $t('iconUrl') }}</MkInput> + <MkInput v-model:value="bannerUrl"><template #icon><Fa :icon="faLink"/></template>{{ $t('bannerUrl') }}</MkInput> + <MkInput v-model:value="tosUrl"><template #icon><Fa :icon="faLink"/></template>{{ $t('tosUrl') }}</MkInput> + <MkInput v-model:value="maintainerName">{{ $t('maintainerName') }}</MkInput> + <MkInput v-model:value="maintainerEmail" type="email"><template #icon><Fa :icon="faEnvelope"/></template>{{ $t('maintainerEmail') }}</MkInput> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin info"> + <section class="_section info"> <div class="_content"> - <mk-input v-model="maxNoteTextLength" type="number" :save="() => save()" style="margin:0;"><template #icon><fa :icon="faPencilAlt"/></template>{{ $t('maxNoteTextLength') }}</mk-input> + <MkInput v-model:value="maxNoteTextLength" type="number" :save="() => save()" style="margin:0;"><template #icon><Fa :icon="faPencilAlt"/></template>{{ $t('maxNoteTextLength') }}</MkInput> </div> <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> + <MkSwitch v-model:value="enableLocalTimeline" @update:value="save()">{{ $t('enableLocalTimeline') }}</MkSwitch> + <MkSwitch v-model:value="enableGlobalTimeline" @update:value="save()">{{ $t('enableGlobalTimeline') }}</MkSwitch> + <MkInfo>{{ $t('disablingTimelinesInfo') }}</MkInfo> </div> <div class="_content"> - <mk-switch v-model="useStarForReactionFallback" @change="save()">{{ $t('useStarForReactionFallback') }}</mk-switch> + <MkSwitch v-model:value="useStarForReactionFallback" @update:value="save()">{{ $t('useStarForReactionFallback') }}</MkSwitch> </div> </section> - <section class="_card _vMargin info"> - <div class="_title"><fa :icon="faUser"/> {{ $t('registration') }}</div> + <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> + <MkSwitch v-model:value="enableRegistration" @update:value="save()">{{ $t('enableRegistration') }}</MkSwitch> + <MkButton v-if="!enableRegistration" @click="invite">{{ $t('invite') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faShieldAlt"/> {{ $t('hcaptcha') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faShieldAlt"/> {{ $t('hcaptcha') }}</div> <div class="_content"> - <mk-switch v-model="enableHcaptcha" ref="enableHcaptcha">{{ $t('enableHcaptcha') }}</mk-switch> + <MkSwitch v-model:value="enableHcaptcha">{{ $t('enableHcaptcha') }}</MkSwitch> <template v-if="enableHcaptcha"> - <mk-input v-model="hcaptchaSiteKey" :disabled="!enableHcaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('hcaptchaSiteKey') }}</mk-input> - <mk-input v-model="hcaptchaSecretKey" :disabled="!enableHcaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('hcaptchaSecretKey') }}</mk-input> + <MkInput v-model:value="hcaptchaSiteKey" :disabled="!enableHcaptcha"><template #icon><Fa :icon="faKey"/></template>{{ $t('hcaptchaSiteKey') }}</MkInput> + <MkInput v-model:value="hcaptchaSecretKey" :disabled="!enableHcaptcha"><template #icon><Fa :icon="faKey"/></template>{{ $t('hcaptchaSecretKey') }}</MkInput> </template> </div> <div class="_content" v-if="enableHcaptcha"> @@ -55,17 +52,17 @@ <captcha v-if="enableHcaptcha" provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faShieldAlt"/> {{ $t('recaptcha') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faShieldAlt"/> {{ $t('recaptcha') }}</div> <div class="_content"> - <mk-switch v-model="enableRecaptcha" ref="enableRecaptcha">{{ $t('enableRecaptcha') }}</mk-switch> + <MkSwitch v-model:value="enableRecaptcha" ref="enableRecaptcha">{{ $t('enableRecaptcha') }}</MkSwitch> <template v-if="enableRecaptcha"> - <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> + <MkInput v-model:value="recaptchaSiteKey" :disabled="!enableRecaptcha"><template #icon><Fa :icon="faKey"/></template>{{ $t('recaptchaSiteKey') }}</MkInput> + <MkInput v-model:value="recaptchaSecretKey" :disabled="!enableRecaptcha"><template #icon><Fa :icon="faKey"/></template>{{ $t('recaptchaSecretKey') }}</MkInput> </template> </div> <div class="_content" v-if="enableRecaptcha && recaptchaSiteKey"> @@ -73,198 +70,198 @@ <captcha v-if="enableRecaptcha" provider="grecaptcha" :sitekey="recaptchaSiteKey"/> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faEnvelope" /> {{ $t('emailConfig') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faEnvelope" /> {{ $t('emailConfig') }}</div> <div class="_content"> - <mk-switch v-model="enableEmail" @change="save()">{{ $t('enableEmail') }}<template #desc>{{ $t('emailConfigInfo') }}</template></mk-switch> - <mk-input v-model="email" type="email" :disabled="!enableEmail">{{ $t('email') }}</mk-input> + <MkSwitch v-model:value="enableEmail" @update:value="save()">{{ $t('enableEmail') }}<template #desc>{{ $t('emailConfigInfo') }}</template></MkSwitch> + <MkInput v-model:value="email" type="email" :disabled="!enableEmail">{{ $t('email') }}</MkInput> <div><b>{{ $t('smtpConfig') }}</b></div> <div class="_inputs"> - <mk-input v-model="smtpHost" :disabled="!enableEmail">{{ $t('smtpHost') }}</mk-input> - <mk-input v-model="smtpPort" type="number" :disabled="!enableEmail">{{ $t('smtpPort') }}</mk-input> + <MkInput v-model:value="smtpHost" :disabled="!enableEmail">{{ $t('smtpHost') }}</MkInput> + <MkInput v-model:value="smtpPort" type="number" :disabled="!enableEmail">{{ $t('smtpPort') }}</MkInput> </div> <div class="_inputs"> - <mk-input v-model="smtpUser" :disabled="!enableEmail">{{ $t('smtpUser') }}</mk-input> - <mk-input v-model="smtpPass" type="password" :disabled="!enableEmail">{{ $t('smtpPass') }}</mk-input> + <MkInput v-model:value="smtpUser" :disabled="!enableEmail">{{ $t('smtpUser') }}</MkInput> + <MkInput v-model:value="smtpPass" type="password" :disabled="!enableEmail">{{ $t('smtpPass') }}</MkInput> </div> - <mk-info>{{ $t('emptyToDisableSmtpAuth') }}</mk-info> - <mk-switch v-model="smtpSecure" :disabled="!enableEmail">{{ $t('smtpSecure') }}<template #desc>{{ $t('smtpSecureInfo') }}</template></mk-switch> + <MkInfo>{{ $t('emptyToDisableSmtpAuth') }}</MkInfo> + <MkSwitch v-model:value="smtpSecure" :disabled="!enableEmail">{{ $t('smtpSecure') }}<template #desc>{{ $t('smtpSecureInfo') }}</template></MkSwitch> <div> - <mk-button :disabled="!enableEmail" inline @click="testEmail()">{{ $t('testEmail') }}</mk-button> - <mk-button :disabled="!enableEmail" primary inline @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton :disabled="!enableEmail" inline @click="testEmail()">{{ $t('testEmail') }}</MkButton> + <MkButton :disabled="!enableEmail" primary inline @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faBolt"/> {{ $t('serviceworker') }}</div> + <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('serviceworkerInfo') }}</template></mk-switch> + <MkSwitch v-model:value="enableServiceWorker">{{ $t('enableServiceworker') }}<template #desc>{{ $t('serviceworkerInfo') }}</template></MkSwitch> <template v-if="enableServiceWorker"> <div class="_inputs"> - <mk-input v-model="swPublicKey" :disabled="!enableServiceWorker"><template #icon><fa :icon="faKey"/></template>Public key</mk-input> - <mk-input v-model="swPrivateKey" :disabled="!enableServiceWorker"><template #icon><fa :icon="faKey"/></template>Private key</mk-input> + <MkInput v-model:value="swPublicKey" :disabled="!enableServiceWorker"><template #icon><Fa :icon="faKey"/></template>Public key</MkInput> + <MkInput v-model:value="swPrivateKey" :disabled="!enableServiceWorker"><template #icon><Fa :icon="faKey"/></template>Private key</MkInput> </div> </template> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faThumbtack"/> {{ $t('pinnedUsers') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faThumbtack"/> {{ $t('pinnedUsers') }}</div> <div class="_content"> - <mk-textarea v-model="pinnedUsers"> + <MkTextarea v-model:value="pinnedUsers"> <template #desc>{{ $t('pinnedUsersDescription') }} <button class="_textButton" @click="addPinUser">{{ $t('addUser') }}</button></template> - </mk-textarea> + </MkTextarea> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faCloud"/> {{ $t('files') }}</div> + <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> + <MkSwitch v-model:value="cacheRemoteFiles">{{ $t('cacheRemoteFiles') }}<template #desc>{{ $t('cacheRemoteFilesDescription') }}</template></MkSwitch> + <MkSwitch v-model:value="proxyRemoteFiles">{{ $t('proxyRemoteFiles') }}<template #desc>{{ $t('proxyRemoteFilesDescription') }}</template></MkSwitch> + <MkInput v-model:value="localDriveCapacityMb" type="number">{{ $t('driveCapacityPerLocalAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></MkInput> + <MkInput v-model:value="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" style="margin-bottom: 0;">{{ $t('driveCapacityPerRemoteAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></MkInput> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faCloud"/> {{ $t('objectStorage') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faCloud"/> {{ $t('objectStorage') }}</div> <div class="_content"> - <mk-switch v-model="useObjectStorage">{{ $t('useObjectStorage') }}</mk-switch> + <MkSwitch v-model:value="useObjectStorage">{{ $t('useObjectStorage') }}</MkSwitch> <template v-if="useObjectStorage"> - <mk-input v-model="objectStorageBaseUrl" :disabled="!useObjectStorage">{{ $t('objectStorageBaseUrl') }}<template #desc>{{ $t('objectStorageBaseUrlDesc') }}</template></mk-input> + <MkInput v-model:value="objectStorageBaseUrl" :disabled="!useObjectStorage">{{ $t('objectStorageBaseUrl') }}<template #desc>{{ $t('objectStorageBaseUrlDesc') }}</template></MkInput> <div class="_inputs"> - <mk-input v-model="objectStorageBucket" :disabled="!useObjectStorage">{{ $t('objectStorageBucket') }}<template #desc>{{ $t('objectStorageBucketDesc') }}</template></mk-input> - <mk-input v-model="objectStoragePrefix" :disabled="!useObjectStorage">{{ $t('objectStoragePrefix') }}<template #desc>{{ $t('objectStoragePrefixDesc') }}</template></mk-input> + <MkInput v-model:value="objectStorageBucket" :disabled="!useObjectStorage">{{ $t('objectStorageBucket') }}<template #desc>{{ $t('objectStorageBucketDesc') }}</template></MkInput> + <MkInput v-model:value="objectStoragePrefix" :disabled="!useObjectStorage">{{ $t('objectStoragePrefix') }}<template #desc>{{ $t('objectStoragePrefixDesc') }}</template></MkInput> </div> - <mk-input v-model="objectStorageEndpoint" :disabled="!useObjectStorage">{{ $t('objectStorageEndpoint') }}<template #desc>{{ $t('objectStorageEndpointDesc') }}</template></mk-input> + <MkInput v-model:value="objectStorageEndpoint" :disabled="!useObjectStorage">{{ $t('objectStorageEndpoint') }}<template #desc>{{ $t('objectStorageEndpointDesc') }}</template></MkInput> <div class="_inputs"> - <mk-input v-model="objectStorageRegion" :disabled="!useObjectStorage">{{ $t('objectStorageRegion') }}<template #desc>{{ $t('objectStorageRegionDesc') }}</template></mk-input> + <MkInput v-model:value="objectStorageRegion" :disabled="!useObjectStorage">{{ $t('objectStorageRegion') }}<template #desc>{{ $t('objectStorageRegionDesc') }}</template></MkInput> </div> <div class="_inputs"> - <mk-input v-model="objectStorageAccessKey" :disabled="!useObjectStorage"><template #icon><fa :icon="faKey"/></template>Access key</mk-input> - <mk-input v-model="objectStorageSecretKey" :disabled="!useObjectStorage"><template #icon><fa :icon="faKey"/></template>Secret key</mk-input> + <MkInput v-model:value="objectStorageAccessKey" :disabled="!useObjectStorage"><template #icon><Fa :icon="faKey"/></template>Access key</MkInput> + <MkInput v-model:value="objectStorageSecretKey" :disabled="!useObjectStorage"><template #icon><Fa :icon="faKey"/></template>Secret key</MkInput> </div> - <mk-switch v-model="objectStorageUseSSL" :disabled="!useObjectStorage">{{ $t('objectStorageUseSSL') }}<template #desc>{{ $t('objectStorageUseSSLDesc') }}</template></mk-switch> - <mk-switch v-model="objectStorageUseProxy" :disabled="!useObjectStorage">{{ $t('objectStorageUseProxy') }}<template #desc>{{ $t('objectStorageUseProxyDesc') }}</template></mk-switch> - <mk-switch v-model="objectStorageSetPublicRead" :disabled="!useObjectStorage">{{ $t('objectStorageSetPublicRead') }}</mk-switch> + <MkSwitch v-model:value="objectStorageUseSSL" :disabled="!useObjectStorage">{{ $t('objectStorageUseSSL') }}<template #desc>{{ $t('objectStorageUseSSLDesc') }}</template></MkSwitch> + <MkSwitch v-model:value="objectStorageUseProxy" :disabled="!useObjectStorage">{{ $t('objectStorageUseProxy') }}<template #desc>{{ $t('objectStorageUseProxyDesc') }}</template></MkSwitch> + <MkSwitch v-model:value="objectStorageSetPublicRead" :disabled="!useObjectStorage">{{ $t('objectStorageSetPublicRead') }}</MkSwitch> </template> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faGhost"/> {{ $t('proxyAccount') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faGhost"/> {{ $t('proxyAccount') }}</div> <div class="_content"> - <mk-input :value="proxyAccount ? proxyAccount.username : null" style="margin: 0;" disabled><template #prefix>@</template>{{ $t('proxyAccount') }}<template #desc>{{ $t('proxyAccountDescription') }}</template></mk-input> - <mk-button primary @click="chooseProxyAccount">{{ $t('chooseProxyAccount') }}</mk-button> + <MkInput :value="proxyAccount ? proxyAccount.username : null" style="margin: 0;" disabled><template #prefix>@</template>{{ $t('proxyAccount') }}<template #desc>{{ $t('proxyAccountDescription') }}</template></MkInput> + <MkButton primary @click="chooseProxyAccount">{{ $t('chooseProxyAccount') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faBan"/> {{ $t('blockedInstances') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faBan"/> {{ $t('blockedInstances') }}</div> <div class="_content"> - <mk-textarea v-model="blockedHosts"> + <MkTextarea v-model:value="blockedHosts"> <template #desc>{{ $t('blockedInstancesDescription') }}</template> - </mk-textarea> + </MkTextarea> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faShareAlt"/> {{ $t('integration') }}</div> + <section class="_section"> + <div class="_title"><Fa :icon="faShareAlt"/> {{ $t('integration') }}</div> <div class="_content"> - <header><fa :icon="faTwitter"/> Twitter</header> - <mk-switch v-model="enableTwitterIntegration">{{ $t('enable') }}</mk-switch> + <header><Fa :icon="faTwitter"/> Twitter</header> + <MkSwitch v-model:value="enableTwitterIntegration">{{ $t('enable') }}</MkSwitch> <template v-if="enableTwitterIntegration"> - <mk-info>Callback URL: {{ `${url}/api/tw/cb` }}</mk-info> - <mk-input v-model="twitterConsumerKey" :disabled="!enableTwitterIntegration"><template #icon><fa :icon="faKey"/></template>Consumer Key</mk-input> - <mk-input v-model="twitterConsumerSecret" :disabled="!enableTwitterIntegration"><template #icon><fa :icon="faKey"/></template>Consumer Secret</mk-input> + <MkInfo>Callback URL: {{ `${url}/api/tw/cb` }}</MkInfo> + <MkInput v-model:value="twitterConsumerKey" :disabled="!enableTwitterIntegration"><template #icon><Fa :icon="faKey"/></template>Consumer Key</MkInput> + <MkInput v-model:value="twitterConsumerSecret" :disabled="!enableTwitterIntegration"><template #icon><Fa :icon="faKey"/></template>Consumer Secret</MkInput> </template> </div> <div class="_content"> - <header><fa :icon="faGithub"/> GitHub</header> - <mk-switch v-model="enableGithubIntegration">{{ $t('enable') }}</mk-switch> + <header><Fa :icon="faGithub"/> GitHub</header> + <MkSwitch v-model:value="enableGithubIntegration">{{ $t('enable') }}</MkSwitch> <template v-if="enableGithubIntegration"> - <mk-info>Callback URL: {{ `${url}/api/gh/cb` }}</mk-info> - <mk-input v-model="githubClientId" :disabled="!enableGithubIntegration"><template #icon><fa :icon="faKey"/></template>Client ID</mk-input> - <mk-input v-model="githubClientSecret" :disabled="!enableGithubIntegration"><template #icon><fa :icon="faKey"/></template>Client Secret</mk-input> + <MkInfo>Callback URL: {{ `${url}/api/gh/cb` }}</MkInfo> + <MkInput v-model:value="githubClientId" :disabled="!enableGithubIntegration"><template #icon><Fa :icon="faKey"/></template>Client ID</MkInput> + <MkInput v-model:value="githubClientSecret" :disabled="!enableGithubIntegration"><template #icon><Fa :icon="faKey"/></template>Client Secret</MkInput> </template> </div> <div class="_content"> - <header><fa :icon="faDiscord"/> Discord</header> - <mk-switch v-model="enableDiscordIntegration">{{ $t('enable') }}</mk-switch> + <header><Fa :icon="faDiscord"/> Discord</header> + <MkSwitch v-model:value="enableDiscordIntegration">{{ $t('enable') }}</MkSwitch> <template v-if="enableDiscordIntegration"> - <mk-info>Callback URL: {{ `${url}/api/dc/cb` }}</mk-info> - <mk-input v-model="discordClientId" :disabled="!enableDiscordIntegration"><template #icon><fa :icon="faKey"/></template>Client ID</mk-input> - <mk-input v-model="discordClientSecret" :disabled="!enableDiscordIntegration"><template #icon><fa :icon="faKey"/></template>Client Secret</mk-input> + <MkInfo>Callback URL: {{ `${url}/api/dc/cb` }}</MkInfo> + <MkInput v-model:value="discordClientId" :disabled="!enableDiscordIntegration"><template #icon><Fa :icon="faKey"/></template>Client ID</MkInput> + <MkInput v-model:value="discordClientSecret" :disabled="!enableDiscordIntegration"><template #icon><Fa :icon="faKey"/></template>Client Secret</MkInput> </template> </div> <div class="_footer"> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faArchway" /> Summaly Proxy</div> + <section class="_section"> + <div class="_title"><Fa :icon="faArchway" /> Summaly Proxy</div> <div class="_content"> - <mk-input v-model="summalyProxy">URL</mk-input> - <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <MkInput v-model:value="summalyProxy">URL</MkInput> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> </div> </section> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent, defineAsyncComponent } from 'vue'; import { faPencilAlt, faShareAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faThumbtack, faUser, faShieldAlt, faKey, faBolt, faArchway } 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 { url } from '../../config'; +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 { url } from '@/config'; import getAcct from '../../../misc/acct/render'; +import * as os from '@/os'; -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('instance') as string - }; - }, - +export default defineComponent({ components: { MkButton, MkInput, MkTextarea, MkSwitch, MkInfo, - Captcha: () => import('../../components/captcha.vue').then(x => x.default), + Captcha: defineAsyncComponent(() => import('@/components/captcha.vue')), }, data() { return { + INFO: { + header: [{ + title: this.$t('instance'), + icon: faCog, + }], + }, url, proxyAccount: null, proxyAccountId: null, @@ -394,16 +391,16 @@ export default Vue.extend({ this.summalyProxy = this.meta.summalyProxy; if (this.proxyAccountId) { - this.$root.api('users/show', { userId: this.proxyAccountId }).then(proxyAccount => { + os.api('users/show', { userId: this.proxyAccountId }).then(proxyAccount => { this.proxyAccount = proxyAccount; }); } }, mounted() { - this.$refs.enableHcaptcha.$on('change', () => { + this.$watch('enableHcaptcha', () => { if (this.enableHcaptcha && this.enableRecaptcha) { - this.$root.dialog({ + os.dialog({ type: 'question', // warning だと間違って cancel するかもしれない showCancelButton: true, title: this.$t('settingGuide'), @@ -418,9 +415,9 @@ export default Vue.extend({ } }); - this.$refs.enableRecaptcha.$on('change', () => { + this.$watch('enableRecaptcha', () => { if (this.enableRecaptcha && this.enableHcaptcha) { - this.$root.dialog({ + os.dialog({ type: 'question', // warning だと間違って cancel するかもしれない showCancelButton: true, title: this.$t('settingGuide'), @@ -438,13 +435,13 @@ export default Vue.extend({ methods: { invite() { - this.$root.api('admin/invite').then(x => { - this.$root.dialog({ + os.api('admin/invite').then(x => { + os.dialog({ type: 'info', text: x.code }); }).catch(e => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e }); @@ -452,7 +449,7 @@ export default Vue.extend({ }, addPinUser() { - this.$root.new(MkUserSelect, {}).$once('selected', user => { + os.selectUser().then(user => { this.pinnedUsers = this.pinnedUsers.trim(); this.pinnedUsers += '\n@' + getAcct(user); this.pinnedUsers = this.pinnedUsers.trim(); @@ -460,7 +457,7 @@ export default Vue.extend({ }, chooseProxyAccount() { - this.$root.new(MkUserSelect, {}).$once('selected', user => { + os.selectUser().then(user => { this.proxyAccount = user; this.proxyAccountId = user.id; this.save(true); @@ -468,17 +465,17 @@ export default Vue.extend({ }, async testEmail() { - this.$root.api('admin/send-email', { + os.api('admin/send-email', { to: this.maintainerEmail, subject: 'Test email', text: 'Yo' }).then(x => { - this.$root.dialog({ + os.dialog({ type: 'success', splash: true }); }).catch(e => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e }); @@ -486,7 +483,7 @@ export default Vue.extend({ }, save(withDialog = false) { - this.$root.api('admin/update-meta', { + os.api('admin/update-meta', { name: this.name, description: this.description, tosUrl: this.tosUrl, @@ -547,13 +544,10 @@ export default Vue.extend({ }).then(() => { this.$store.dispatch('instance/fetch'); if (withDialog) { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); } }).catch(e => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e }); diff --git a/src/client/pages/instance/user-dialog.vue b/src/client/pages/instance/user-dialog.vue new file mode 100644 index 0000000000..3cf30e115f --- /dev/null +++ b/src/client/pages/instance/user-dialog.vue @@ -0,0 +1,233 @@ +<template> +<XModalWindow ref="dialog" + :width="370" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> + <template #header v-if="user"><MkUserName class="name" :user="user"/></template> + <div class="vrcsvlkm" v-if="user && info"> + <div class="_section"> + <div class="banner" :style="bannerStyle"> + <MkAvatar class="avatar" :user="user"/> + </div> + </div> + <div class="_section"> + <div class="title"> + <span class="acct">@{{ acct(user) }}</span> + </div> + <div class="status"> + <span class="staff" v-if="user.isAdmin"><Fa :icon="faBookmark"/></span> + <span class="staff" v-if="user.isModerator"><Fa :icon="farBookmark"/></span> + <span class="punished" v-if="user.isSilenced"><Fa :icon="faMicrophoneSlash"/></span> + <span class="punished" v-if="user.isSuspended"><Fa :icon="faSnowflake"/></span> + </div> + </div> + <div class="_section"> + <div class="_content"> + <MkSwitch v-if="user.host == null && $store.state.i.isAdmin && (this.moderator || !user.isAdmin)" @update:value="toggleModerator" v-model:value="moderator">{{ $t('moderator') }}</MkSwitch> + <MkSwitch @update:value="toggleSilence" v-model:value="silenced">{{ $t('silence') }}</MkSwitch> + <MkSwitch @update:value="toggleSuspend" v-model:value="suspended">{{ $t('suspend') }}</MkSwitch> + </div> + </div> + <div class="_section"> + <div class="_content"> + <MkButton full @click="openProfile"><Fa :icon="faExternalLinkSquareAlt"/> {{ $t('profile') }}</MkButton> + <MkButton full v-if="user.host != null" @click="updateRemoteUser"><Fa :icon="faSync"/> {{ $t('updateRemoteUser') }}</MkButton> + <MkButton full @click="resetPassword"><Fa :icon="faKey"/> {{ $t('resetPassword') }}</MkButton> + <MkButton full @click="deleteAllFiles" danger><Fa :icon="faTrashAlt"/> {{ $t('deleteAllFiles') }}</MkButton> + </div> + </div> + <div class="_section"> + <details class="_content rawdata"> + <pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre> + </details> + </div> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import { faTimes, faBookmark, faKey, faSync, faMicrophoneSlash, faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons'; +import { faSnowflake, faTrashAlt, faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import Progress from '@/scripts/loading'; +import { acct, userPage } from '../../filters/user'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkSwitch, + XModalWindow, + }, + + props: { + userId: { + required: true, + } + }, + + emits: ['closed'], + + data() { + return { + user: null, + info: null, + moderator: false, + silenced: false, + suspended: false, + faTimes, faBookmark, farBookmark, faKey, faSync, faMicrophoneSlash, faSnowflake, faTrashAlt, faExternalLinkSquareAlt + }; + }, + + computed: { + bannerStyle(): any { + if (this.user.bannerUrl == null) return {}; + return { + backgroundImage: `url(${ this.user.bannerUrl })` + }; + }, + }, + + created() { + this.fetch(); + }, + + methods: { + async fetch() { + Progress.start(); + this.user = await os.api('users/show', { userId: this.userId }); + this.info = await os.api('admin/show-user', { userId: this.userId }); + this.moderator = this.info.isModerator; + this.silenced = this.info.isSilenced; + this.suspended = this.info.isSuspended; + Progress.done(); + }, + + /** 処理対象ユーザーの情報を更新する */ + async refreshUser() { + this.user = await os.api('users/show', { userId: this.user.id }); + this.info = await os.api('admin/show-user', { userId: this.user.id }); + }, + + openProfile() { + window.open(userPage(this.user, null, true), '_blank'); + }, + + async updateRemoteUser() { + await os.api('admin/update-remote-user', { userId: this.user.id }).then(res => { + os.success(); + }); + await this.refreshUser(); + }, + + async resetPassword() { + os.apiWithDialog('admin/reset-password', { + userId: this.user.id, + }, undefined, ({ password }) => { + os.dialog({ + type: 'success', + text: this.$t('newPasswordIs', { password }) + }); + }); + }, + + async toggleSilence(v) { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: v ? this.$t('silenceConfirm') : this.$t('unsilenceConfirm'), + }); + if (confirm.canceled) { + this.silenced = !v; + } else { + await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id }); + await this.refreshUser(); + } + }, + + async toggleSuspend(v) { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: v ? this.$t('suspendConfirm') : this.$t('unsuspendConfirm'), + }); + if (confirm.canceled) { + this.suspended = !v; + } else { + await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id }); + await this.refreshUser(); + } + }, + + async toggleModerator(v) { + await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id }); + await this.refreshUser(); + }, + + async deleteAllFiles() { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: this.$t('deleteAllFilesConfirm'), + }); + if (confirm.canceled) return; + const process = async () => { + await os.api('admin/delete-all-files-of-a-user', { userId: this.user.id }); + os.success(); + }; + await process().catch(e => { + os.dialog({ + type: 'error', + text: e.toString() + }); + }); + await this.refreshUser(); + }, + + acct + } +}); +</script> + +<style lang="scss" scoped> +.vrcsvlkm { + > ._section { + > .banner { + position: relative; + height: 100px; + background-color: #4c5e6d; + background-size: cover; + background-position: center; + border-radius: 8px; + + > .avatar { + position: absolute; + top: 60px; + width: 64px; + height: 64px; + left: 0; + right: 0; + margin: 0 auto; + border: solid 4px var(--panel); + } + } + + > .title { + text-align: center; + } + + > .status { + text-align: center; + margin-top: 8px; + } + + > .rawdata { + overflow: auto; + } + } +} +</style> diff --git a/src/client/pages/instance/users.user.vue b/src/client/pages/instance/users.user.vue deleted file mode 100644 index 25f0260637..0000000000 --- a/src/client/pages/instance/users.user.vue +++ /dev/null @@ -1,206 +0,0 @@ -<template> -<div class="vrcsvlkm" v-if="user && info"> - <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> - - <section class="_card"> - <div class="_title"> - <mk-avatar class="avatar" :user="user"/> - <mk-user-name class="name" :user="user"/> - <span class="acct">@{{ user | acct }}</span> - <span class="staff" v-if="user.isAdmin"><fa :icon="faBookmark"/></span> - <span class="staff" v-if="user.isModerator"><fa :icon="farBookmark"/></span> - <span class="punished" v-if="user.isSilenced"><fa :icon="faMicrophoneSlash"/></span> - <span class="punished" v-if="user.isSuspended"><fa :icon="faSnowflake"/></span> - </div> - <div class="_content actions"> - <div style="flex: 1; padding-left: 1em;"> - <mk-switch v-if="user.host == null && $store.state.i.isAdmin && (this.moderator || !user.isAdmin)" @change="toggleModerator()" v-model="moderator">{{ $t('moderator') }}</mk-switch> - <mk-switch @change="toggleSilence()" v-model="silenced">{{ $t('silence') }}</mk-switch> - <mk-switch @change="toggleSuspend()" v-model="suspended">{{ $t('suspend') }}</mk-switch> - </div> - <div style="flex: 1; padding-left: 1em;"> - <mk-button @click="openProfile"><fa :icon="faExternalLinkSquareAlt"/> {{ $t('profile')}}</mk-button> - <mk-button v-if="user.host != null" @click="updateRemoteUser"><fa :icon="faSync"/> {{ $t('updateRemoteUser') }}</mk-button> - <mk-button @click="resetPassword"><fa :icon="faKey"/> {{ $t('resetPassword') }}</mk-button> - <mk-button @click="deleteAllFiles"><fa :icon="faTrashAlt"/> {{ $t('deleteAllFiles') }}</mk-button> - </div> - </div> - <div class="_content rawdata"> - <pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre> - </div> - </section> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faTimes, faBookmark, faKey, faSync, faMicrophoneSlash, faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons'; -import { faSnowflake, faTrashAlt, faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons'; -import MkButton from '../../components/ui/button.vue'; -import MkSwitch from '../../components/ui/switch.vue'; -import Progress from '../../scripts/loading'; - -export default Vue.extend({ - components: { - MkButton, - MkSwitch, - }, - - data() { - return { - user: null, - info: null, - moderator: false, - silenced: false, - suspended: false, - faTimes, faBookmark, farBookmark, faKey, faSync, faMicrophoneSlash, faSnowflake, faTrashAlt, faExternalLinkSquareAlt - }; - }, - - watch: { - $route: 'fetch' - }, - - created() { - this.fetch(); - }, - - methods: { - async fetch() { - Progress.start(); - this.user = await this.$root.api('users/show', { userId: this.$route.params.user }); - this.info = await this.$root.api('admin/show-user', { userId: this.$route.params.user }); - this.moderator = this.info.isModerator; - this.silenced = this.info.isSilenced; - this.suspended = this.info.isSuspended; - Progress.done(); - }, - - /** 処理対象ユーザーの情報を更新する */ - async refreshUser() { - this.user = await this.$root.api('users/show', { userId: this.user.id }); - this.info = await this.$root.api('admin/show-user', { userId: this.user.id }); - }, - - openProfile() { - window.open(Vue.filter('userPage')(this.user, null, true), '_blank'); - }, - - async updateRemoteUser() { - await this.$root.api('admin/update-remote-user', { userId: this.user.id }).then(res => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }); - await this.refreshUser(); - }, - - async resetPassword() { - const dialog = this.$root.dialog({ - type: 'waiting', - iconOnly: true - }); - - this.$root.api('admin/reset-password', { - userId: this.user.id, - }).then(({ password }) => { - this.$root.dialog({ - type: 'success', - text: this.$t('newPasswordIs', { password }) - }); - }).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 { - await this.$root.api(this.silenced ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id }); - await this.refreshUser(); - } - }, - - 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 { - await this.$root.api(this.suspended ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id }); - await this.refreshUser(); - } - }, - - async toggleModerator() { - await this.$root.api(this.moderator ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id }); - await this.refreshUser(); - }, - - async deleteAllFiles() { - const confirm = await this.$root.dialog({ - type: 'warning', - showCancelButton: true, - text: this.$t('deleteAllFilesConfirm'), - }); - if (confirm.canceled) return; - const process = async () => { - await this.$root.api('admin/delete-all-files-of-a-user', { userId: this.user.id }); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }; - await process().catch(e => { - this.$root.dialog({ - type: 'error', - text: e.toString() - }); - }); - await this.refreshUser(); - }, - } -}); -</script> - -<style lang="scss" scoped> -.vrcsvlkm { - display: flex; - flex-direction: column; - - > ._card { - > .actions { - display: flex; - box-sizing: border-box; - text-align: left; - align-items: center; - margin-top: 16px; - margin-bottom: 16px; - } - - > .rawdata { - > pre > code { - display: block; - width: 100%; - height: 100%; - } - } - } -} -</style> diff --git a/src/client/pages/instance/users.vue b/src/client/pages/instance/users.vue index cf3786c965..b891ed8412 100644 --- a/src/client/pages/instance/users.vue +++ b/src/client/pages/instance/users.vue @@ -1,33 +1,33 @@ <template> <div class="mk-instance-users"> - <portal to="icon"><fa :icon="faUsers"/></portal> - <portal to="title">{{ $t('users') }}</portal> + <div class="_section"> + <div class="_content"> + <MkButton inline primary @click="addUser()"><Fa :icon="faPlus"/> {{ $t('addUser') }}</MkButton> + </div> + </div> - <section class="_card _vMargin lookup"> - <div class="_title"><fa :icon="faSearch"/> {{ $t('lookup') }}</div> + <div 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()"> + <MkInput class="target" v-model:value="target" type="text" @enter="showUser()"> <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="searchUser()"><fa :icon="faSearch"/> {{ $t('search') }}</mk-button> + </MkInput> + <MkButton @click="showUser()" primary><Fa :icon="faSearch"/> {{ $t('lookup') }}</MkButton> </div> - </section> + </div> - <section class="_card _vMargin users"> - <div class="_title"><fa :icon="faUsers"/> {{ $t('users') }}</div> + <div class="_section users"> + <div class="_title"><Fa :icon="faUsers"/> {{ $t('users') }}</div> <div class="_content"> <div class="inputs" style="display: flex;"> - <mk-select v-model="sort" style="margin: 0; flex: 1;"> + <MkSelect v-model:value="sort" style="margin: 0; flex: 1;"> <template #label>{{ $t('sort') }}</template> <option value="-createdAt">{{ $t('registeredDate') }} ({{ $t('ascendingOrder') }})</option> <option value="+createdAt">{{ $t('registeredDate') }} ({{ $t('descendingOrder') }})</option> <option value="-updatedAt">{{ $t('lastUsed') }} ({{ $t('ascendingOrder') }})</option> <option value="+updatedAt">{{ $t('lastUsed') }} ({{ $t('descendingOrder') }})</option> - </mk-select> - <mk-select v-model="state" style="margin: 0; flex: 1;"> + </MkSelect> + <MkSelect v-model:value="state" style="margin: 0; flex: 1;"> <template #label>{{ $t('state') }}</template> <option value="all">{{ $t('all') }}</option> <option value="available">{{ $t('normal') }}</option> @@ -35,71 +35,62 @@ <option value="moderator">{{ $t('moderator') }}</option> <option value="silenced">{{ $t('silence') }}</option> <option value="suspended">{{ $t('suspend') }}</option> - </mk-select> - <mk-select v-model="origin" style="margin: 0; flex: 1;"> + </MkSelect> + <MkSelect v-model:value="origin" style="margin: 0; flex: 1;"> <template #label>{{ $t('instance') }}</template> <option value="combined">{{ $t('all') }}</option> <option value="local">{{ $t('local') }}</option> <option value="remote">{{ $t('remote') }}</option> - </mk-select> + </MkSelect> </div> <div class="inputs" style="display: flex; padding-top: 1.2em;"> - <mk-input v-model="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false" @input="$refs.users.reload()"> + <MkInput v-model:value="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:value="$refs.users.reload()"> <span>{{ $t('username') }}</span> - </mk-input> - <mk-input v-model="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" @input="$refs.users.reload()" :disabled="pagination.params().origin === 'local'"> + </MkInput> + <MkInput v-model:value="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:value="$refs.users.reload()" :disabled="pagination.params().origin === 'local'"> <span>{{ $t('host') }}</span> - </mk-input> + </MkInput> </div> - </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" @click="show(user)"> - <mk-avatar class="avatar" :user="user" :disable-link="true"/> + + <MkPagination :pagination="pagination" #default="{items}" class="users" ref="users" :auto-margin="false"> + <button class="user _panel _button _vMargin" v-for="user in items" :key="user.id" @click="show(user)"> + <MkAvatar class="avatar" :user="user" :disable-link="true"/> <div class="body"> <header> - <mk-user-name class="name" :user="user"/> - <span class="acct">@{{ user | acct }}</span> - <span class="staff" v-if="user.isAdmin"><fa :icon="faBookmark"/></span> - <span class="staff" v-if="user.isModerator"><fa :icon="farBookmark"/></span> - <span class="punished" v-if="user.isSilenced"><fa :icon="faMicrophoneSlash"/></span> - <span class="punished" v-if="user.isSuspended"><fa :icon="faSnowflake"/></span> + <MkUserName class="name" :user="user"/> + <span class="acct">@{{ acct(user) }}</span> + <span class="staff" v-if="user.isAdmin"><Fa :icon="faBookmark"/></span> + <span class="staff" v-if="user.isModerator"><Fa :icon="farBookmark"/></span> + <span class="punished" v-if="user.isSilenced"><Fa :icon="faMicrophoneSlash"/></span> + <span class="punished" v-if="user.isSuspended"><Fa :icon="faSnowflake"/></span> </header> <div> - <span>{{ $t('lastUsed') }}: <mk-time :time="user.updatedAt" mode="detail"/></span> + <span>{{ $t('lastUsed') }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span> </div> <div> - <span>{{ $t('registeredDate') }}: <mk-time :time="user.createdAt" mode="detail"/></span> + <span>{{ $t('registeredDate') }}: <MkTime :time="user.createdAt" mode="detail"/></span> </div> </div> </button> - </mk-pagination> - </div> - <div class="_footer"> - <mk-button inline primary @click="addUser()"><fa :icon="faPlus"/> {{ $t('addUser') }}</mk-button> + </MkPagination> </div> - </section> + </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faPlus, faUsers, faSearch, faBookmark, faMicrophoneSlash } from '@fortawesome/free-solid-svg-icons'; import { faSnowflake, faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons'; import parseAcct from '../../../misc/acct/parse'; -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 MkUserSelect from '../../components/user-select.vue'; - -export default Vue.extend({ - metaInfo() { - return { - title: `${this.$t('users')} | ${this.$t('instance')}` - }; - }, +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 { acct } from '../../filters/user'; +import * as os from '@/os'; +export default defineComponent({ components: { MkButton, MkInput, @@ -109,6 +100,16 @@ export default Vue.extend({ data() { return { + INFO: { + header: [{ + title: this.$t('users'), + icon: faUsers + }], + action: { + icon: faSearch, + handler: this.searchUser + } + }, target: '', sort: '+createdAt', state: 'all', @@ -147,12 +148,12 @@ export default Vue.extend({ /** テキストエリアのユーザーを解決する */ 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 }); + const usernamePromise = os.api('users/show', parseAcct(this.target)); + const idPromise = os.api('users/show', { userId: this.target }); let _notFound = false; const notFound = () => { if (_notFound) { - this.$root.dialog({ + os.dialog({ type: 'error', text: this.$t('noSuchUser') }); @@ -179,51 +180,39 @@ export default Vue.extend({ }, searchUser() { - this.$root.new(MkUserSelect, {}).$once('selected', user => { + os.selectUser().then(user => { this.show(user); }); }, async addUser() { - const { canceled: canceled1, result: username } = await this.$root.dialog({ + const { canceled: canceled1, result: username } = await os.dialog({ title: this.$t('username'), input: true }); if (canceled1) return; - const { canceled: canceled2, result: password } = await this.$root.dialog({ + const { canceled: canceled2, result: password } = await os.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', { + os.apiWithDialog('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) { - this.$router.push('./users/' + user.id); - } + os.popup(await import('./user-dialog.vue'), { + userId: user.id + }, {}, 'closed'); + }, + + acct } }); </script> @@ -232,28 +221,32 @@ export default Vue.extend({ .mk-instance-users { > .users { > ._content { - max-height: 300px; - overflow: auto; - > .users { + margin-top: var(--margin); + > .user { display: flex; width: 100%; box-sizing: border-box; text-align: left; align-items: center; + padding: 16px; + + &:hover { + color: var(--accent); + } > .avatar { - width: 64px; - height: 64px; + width: 60px; + height: 60px; } > .body { margin-left: 0.3em; - padding: 8px; + padding: 0 8px; flex: 1; - @media (max-width 500px) { + @media (max-width: 500px) { font-size: 14px; } diff --git a/src/client/pages/mentions.vue b/src/client/pages/mentions.vue index 8c57a1342d..0ad3def03c 100644 --- a/src/client/pages/mentions.vue +++ b/src/client/pages/mentions.vue @@ -1,30 +1,28 @@ <template> -<div> - <portal to="icon"><fa :icon="faAt"/></portal> - <portal to="title">{{ $t('mentions') }}</portal> - <x-notes :pagination="pagination" @before="before()" @after="after()"/> +<div class="_section"> + <XNotes class="_content" :pagination="pagination" @before="before()" @after="after()"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } 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 - }; - }, +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +export default defineComponent({ components: { XNotes }, data() { return { + INFO: { + header: [{ + title: this.$t('mentions'), + icon: faAt + }], + }, pagination: { endpoint: 'notes/mentions', limit: 10, diff --git a/src/client/pages/messages.vue b/src/client/pages/messages.vue index e607b86546..4803891d0e 100644 --- a/src/client/pages/messages.vue +++ b/src/client/pages/messages.vue @@ -1,30 +1,28 @@ <template> <div> - <portal to="icon"><fa :icon="faEnvelope"/></portal> - <portal to="title">{{ $t('directNotes') }}</portal> - <x-notes :pagination="pagination" @before="before()" @after="after()"/> + <XNotes :pagination="pagination" @before="before()" @after="after()"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } 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 - }; - }, +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +export default defineComponent({ components: { XNotes }, data() { return { + INFO: { + header: [{ + title: this.$t('directNotes'), + icon: faEnvelope + }], + }, pagination: { endpoint: 'notes/mentions', limit: 10, diff --git a/src/client/pages/messaging/index.vue b/src/client/pages/messaging/index.vue index 049d918595..07b1cbab83 100644 --- a/src/client/pages/messaging/index.vue +++ b/src/client/pages/messaging/index.vue @@ -1,58 +1,66 @@ <template> -<div class="mk-messaging" v-size="{ max: [400] }"> - <portal to="icon"><fa :icon="faComments"/></portal> - <portal to="title">{{ $t('messaging') }}</portal> +<div class="_section"> + <div class="mk-messaging _content" v-size="{ max: [400] }"> + <MkButton @click="start" primary class="start"><Fa :icon="faPlus"/> {{ $t('startMessaging') }}</MkButton> - <mk-button @click="start" primary class="start"><fa :icon="faPlus"/> {{ $t('startMessaging') }}</mk-button> - - <div class="history" v-if="messages.length > 0"> - <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 class="history" v-if="messages.length > 0"> + <router-link v-for="(message, i) in messages" + class="message _panel" + :class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($store.state.i.id) : message.isRead }" + :to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`" + :data-index="i" + :key="message.id" + @click.prevent="go(message)" + > + <div> + <MkAvatar 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> + <MkTime :time="message.createdAt"/> + </header> + <header v-else> + <span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span> + <span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span> + <MkTime :time="message.createdAt"/> + </header> + <div class="body"> + <p class="text"><span class="me" v-if="isMe(message)">{{ $t('you') }}:</span>{{ message.text }}</p> + </div> </div> - </div> - </router-link> - </div> - <div class="_fullinfo" v-if="!fetching && messages.length == 0"> - <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> - <div>{{ $t('noHistory') }}</div> + </router-link> + </div> + <div class="_fullinfo" v-if="!fetching && messages.length == 0"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $t('noHistory') }}</div> + </div> + <MkLoading v-if="fetching"/> </div> - <mk-loading v-if="fetching"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineAsyncComponent, defineComponent } from 'vue'; import { faUser, faUsers, faComments, faPlus } from '@fortawesome/free-solid-svg-icons'; import getAcct from '../../../misc/acct/render'; -import MkButton from '../../components/ui/button.vue'; -import MkUserSelect from '../../components/user-select.vue'; +import MkButton from '@/components/ui/button.vue'; +import { acct } from '../../filters/user'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkButton }, + inject: ['navHook'], + data() { return { + INFO: { + header: [{ + title: this.$t('messaging'), + icon: faComments + }] + }, fetching: true, moreFetching: false, messages: [], @@ -62,13 +70,13 @@ export default Vue.extend({ }, mounted() { - this.connection = this.$root.stream.useSharedConnection('messagingIndex'); + this.connection = os.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 => { + os.api('messaging/history', { group: false }).then(userMessages => { + os.api('messaging/history', { group: true }).then(groupMessages => { const messages = userMessages.concat(groupMessages); messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); this.messages = messages; @@ -77,11 +85,23 @@ export default Vue.extend({ }); }, - beforeDestroy() { + beforeUnmount() { this.connection.dispose(); }, methods: { + go(message) { + const url = message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(this.isMe(message) ? message.recipient : message.user)}`; + if (this.navHook) { + this.navHook(url, defineAsyncComponent(() => import('@/pages/messaging/messaging-room.vue')), { + userAcct: message.groupId ? null : getAcct(this.isMe(message) ? message.recipient : message.user), + groupId: message.groupId + }); + } else { + this.$router.push(url); + } + }, + getAcct, isMe(message) { @@ -115,39 +135,35 @@ export default Vue.extend({ }, start(ev) { - this.$root.menu({ - items: [{ - text: this.$t('messagingWithUser'), - icon: faUser, - action: () => { this.startUser() } - }, { - text: this.$t('messagingWithGroup'), - icon: faUsers, - action: () => { this.startGroup() } - }], - noCenter: true, - source: ev.currentTarget || ev.target, - }); + os.modalMenu([{ + text: this.$t('messagingWithUser'), + icon: faUser, + action: () => { this.startUser() } + }, { + text: this.$t('messagingWithGroup'), + icon: faUsers, + action: () => { this.startGroup() } + }], ev.currentTarget || ev.target); }, async startUser() { - this.$root.new(MkUserSelect, {}).$once('selected', user => { + os.selectUser().then(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 groups1 = await os.api('users/groups/owned'); + const groups2 = await os.api('users/groups/joined'); if (groups1.length === 0 && groups2.length === 0) { - this.$root.dialog({ + os.dialog({ type: 'warning', title: this.$t('youHaveNoGroups'), text: this.$t('joinOrCreateGroup'), }); return; } - const { canceled, result: group } = await this.$root.dialog({ + const { canceled, result: group } = await os.dialog({ type: null, title: this.$t('group'), select: { @@ -159,7 +175,9 @@ export default Vue.extend({ }); if (canceled) return; this.$router.push(`/my/messaging/group/${group.id}`); - } + }, + + acct } }); </script> @@ -191,12 +209,12 @@ export default Vue.extend({ &:active { } - &[data-is-read], - &[data-is-me] { + &.isRead, + &.isMe { opacity: 0.8; } - &:not([data-is-me]):not([data-is-read]) { + &:not(.isMe):not(.isRead) { > div { background-image: url("/assets/unread.svg"); background-repeat: no-repeat; @@ -283,7 +301,7 @@ export default Vue.extend({ &.max-width_400px { > .history { > .message { - &:not([data-is-me]):not([data-is-read]) { + &:not(.isMe):not(.isRead) { > div { background-image: none; border-left: solid 4px #3aa2dc; diff --git a/src/client/pages/messaging/messaging-room.form.vue b/src/client/pages/messaging/messaging-room.form.vue index eda8914c4a..3b5b9aa966 100644 --- a/src/client/pages/messaging/messaging-room.form.vue +++ b/src/client/pages/messaging/messaging-room.form.vue @@ -9,31 +9,28 @@ @keypress="onKeypress" @paste="onPaste" :placeholder="$t('inputMessageHere')" - v-autocomplete="{ model: 'text' }" ></textarea> <div class="file" @click="file = null" v-if="file">{{ file.name }}</div> - <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> + <template v-if="!sending"><Fa :icon="faPaperPlane"/></template><template v-if="sending"><Fa icon="spinner .spin"/></template> </button> - <button class="_button" @click="chooseFile"><fa :icon="faPhotoVideo"/></button> - <button class="_button" @click="insertEmoji"><fa :icon="faLaughSquint"/></button> + <button class="_button" @click="chooseFile"><Fa :icon="faPhotoVideo"/></button> + <button class="_button" @click="insertEmoji"><Fa :icon="faLaughSquint"/></button> <input ref="file" type="file" @change="onChangeFile"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent, defineAsyncComponent } from 'vue'; import { faPaperPlane, faPhotoVideo, faLaughSquint } from '@fortawesome/free-solid-svg-icons'; import insertTextAtCursor from 'insert-text-at-cursor'; import * as autosize from 'autosize'; import { formatTimeString } from '../../../misc/format-time-string'; -import { selectFile } from '../../scripts/select-file'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import { Autocomplete } from '@/scripts/autocomplete'; -export default Vue.extend({ - components: { - XUploader: () => import('../../components/uploader.vue').then(m => m.default), - }, +export default defineComponent({ props: { user: { type: Object, @@ -69,15 +66,14 @@ export default Vue.extend({ }, file() { this.saveDraft(); - - if (this.room.isBottom()) { - this.room.scrollToBottom(); - } } }, mounted() { autosize(this.$refs.text); + // TODO: detach when unmount + new Autocomplete(this.$refs.text, this, { model: 'text' }); + // 書きかけの投稿を復元 const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey]; if (draft) { @@ -97,7 +93,7 @@ export default Vue.extend({ const ext = lio >= 0 ? file.name.slice(lio) : ''; const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.settings.pastedFileName).replace(/{{number}}/g, '1')}${ext}`; const name = this.$store.state.settings.pasteDialog - ? await this.$root.dialog({ + ? await os.dialog({ title: this.$t('enterFileName'), input: { default: formatted @@ -109,7 +105,7 @@ export default Vue.extend({ } } else { if (items[0].kind == 'file') { - this.$root.dialog({ + os.dialog({ type: 'error', text: this.$t('onlyOneFileCanBeAttached') }); @@ -119,7 +115,7 @@ export default Vue.extend({ onDragover(e) { const isFile = e.dataTransfer.items[0].kind == 'file'; - const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; if (isFile || isDriveFile) { e.preventDefault(); e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; @@ -134,7 +130,7 @@ export default Vue.extend({ return; } else if (e.dataTransfer.files.length > 1) { e.preventDefault(); - this.$root.dialog({ + os.dialog({ type: 'error', text: this.$t('onlyOneFileCanBeAttached') }); @@ -142,7 +138,7 @@ export default Vue.extend({ } //#region ドライブのファイル - const driveFile = e.dataTransfer.getData('mk_drive_file'); + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); if (driveFile != null && driveFile != '') { this.file = JSON.parse(driveFile); e.preventDefault(); @@ -157,7 +153,7 @@ export default Vue.extend({ }, chooseFile(e) { - selectFile(this, e.currentTarget || e.target, this.$t('selectFile'), false).then(file => { + selectFile(e.currentTarget || e.target, this.$t('selectFile'), false).then(file => { this.file = file; }); }, @@ -167,16 +163,14 @@ export default Vue.extend({ }, upload(file: File, name?: string) { - (this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder, name); - }, - - onUploaded(file) { - this.file = file; + os.upload(file, this.$store.state.settings.uploadFolder, name).then(res => { + this.file = res; + }); }, send() { this.sending = true; - this.$root.api('messaging/messages/create', { + os.api('messaging/messages/create', { userId: this.user ? this.user.id : undefined, groupId: this.group ? this.group.id : undefined, text: this.text ? this.text : undefined, @@ -219,11 +213,8 @@ export default Vue.extend({ }, async insertEmoji(ev) { - const vm = this.$root.new(await import('../../components/emoji-picker.vue').then(m => m.default), { - source: ev.currentTarget || ev.target - }).$once('chosen', emoji => { + os.pickEmoji(ev.currentTarget || ev.target).then(emoji => { insertTextAtCursor(this.$refs.text, emoji); - vm.close(); }); } } diff --git a/src/client/pages/messaging/messaging-room.message.vue b/src/client/pages/messaging/messaging-room.message.vue index 40ee4e8b92..37fc6ba319 100644 --- a/src/client/pages/messaging/messaging-room.message.vue +++ b/src/client/pages/messaging/messaging-room.message.vue @@ -1,13 +1,13 @@ <template> -<div class="thvuemwp" :data-is-me="isMe"> - <mk-avatar class="avatar" :user="message.user"/> +<div class="thvuemwp" :class="{ isMe }"> + <MkAvatar class="avatar" :user="message.user"/> <div class="content"> - <div class="balloon" :data-no-text="message.text == null"> + <div class="balloon" :class="{ noText: message.text == null }" > <button class="delete-button" v-if="isMe" :title="$t('delete')" @click="del"> <img src="/assets/remove.png" alt="Delete"/> </button> <div class="content" v-if="!message.isDeleted"> - <mfm class="text" v-if="message.text" ref="text" :text="message.text" :i="$store.state.i"/> + <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"/> @@ -20,7 +20,7 @@ </div> </div> <div></div> - <mk-url-preview v-for="url in urls" :url="url" :key="url" style="margin: 8px 0;"/> + <MkUrlPreview v-for="url in urls" :url="url" :key="url" style="margin: 8px 0;"/> <footer> <template v-if="isGroup"> <span class="read" v-if="message.reads.length > 0">{{ $t('messageRead') }} {{ message.reads.length }}</span> @@ -28,20 +28,21 @@ <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> + <MkTime :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 { defineComponent } from 'vue'; import { parse } from '../../../mfm/parse'; import { unique } from '../../../prelude/array'; -import MkUrlPreview from '../../components/url-preview.vue'; +import MkUrlPreview from '@/components/url-preview.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkUrlPreview }, @@ -70,7 +71,7 @@ export default Vue.extend({ }, methods: { del() { - this.$root.api('messaging/messages/delete', { + os.api('messaging/messages/delete', { messageId: this.message.id }); } @@ -240,7 +241,7 @@ export default Vue.extend({ } } - &:not([data-is-me]) { + &:not(.isMe) { padding-left: var(--margin); > .content { @@ -251,11 +252,11 @@ export default Vue.extend({ $color: var(--messageBg); background: $color; - &[data-no-text] { + &.noText { background: transparent; } - &:not([data-no-text]):before { + &:not(.noText):before { left: -14px; border-top: solid 8px transparent; border-right: solid 8px $color; @@ -276,7 +277,7 @@ export default Vue.extend({ } } - &[data-is-me] { + &.isMe { flex-direction: row-reverse; padding-right: var(--margin); @@ -289,11 +290,11 @@ export default Vue.extend({ background: $me-balloon-color; text-align: left; - &[data-no-text] { + &.noText { background: transparent; } - &:not([data-no-text]):before { + &:not(.noText):before { right: -14px; left: auto; border-top: solid 8px transparent; @@ -309,7 +310,7 @@ export default Vue.extend({ } > .text { - &, ::v-deep * { + &, ::v-deep(*) { color: #fff !important; } } @@ -325,11 +326,5 @@ export default Vue.extend({ } } } - - &[data-is-deleted] { - > .balloon { - opacity: 0.5; - } - } } </style> diff --git a/src/client/pages/messaging/messaging-room.vue b/src/client/pages/messaging/messaging-room.vue index abff3062c9..4210b8cf89 100644 --- a/src/client/pages/messaging/messaging-room.vue +++ b/src/client/pages/messaging/messaging-room.vue @@ -1,57 +1,85 @@ <template> -<div class="mk-messaging-room" +<div class="_section" @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="icon"><fa :icon="faUsers"/></portal> - <portal to="title">{{ group.name }}</portal> - </template> - - <div class="body"> - <mk-loading v-if="fetching"/> - <p class="empty" v-if="!fetching && messages.length == 0"><fa :icon="faInfoCircle"/>{{ $t('noMessagesYet') }}</p> - <p class="no-history" v-if="!fetching && messages.length > 0 && !existMoreMessages"><fa :icon="faFlag"/>{{ $t('noMoreHistory') }}</p> - <button class="more _button" ref="loadMore" :class="{ fetching: fetchingMoreMessages }" v-show="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages"> - <template v-if="fetchingMoreMessages"><fa icon="spinner" pulse fixed-width/></template>{{ fetchingMoreMessages ? $t('loading') : $t('loadMore') }} - </button> - <x-list class="messages" :items="messages" v-slot="{ item: message }" direction="up" reversed> - <x-message :message="message" :is-group="group != null" :key="message.id"/> - </x-list> + <div class="_content mk-messaging-room"> + <div class="body"> + <MkLoading v-if="fetching"/> + <p class="empty" v-if="!fetching && messages.length == 0"><Fa :icon="faInfoCircle"/>{{ $t('noMessagesYet') }}</p> + <p class="no-history" v-if="!fetching && messages.length > 0 && !existMoreMessages"><Fa :icon="faFlag"/>{{ $t('noMoreHistory') }}</p> + <button class="more _button" ref="loadMore" :class="{ fetching: fetchingMoreMessages }" v-show="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages"> + <template v-if="fetchingMoreMessages"><Fa icon="spinner" pulse fixed-width/></template>{{ fetchingMoreMessages ? $t('loading') : $t('loadMore') }} + </button> + <XList class="messages" :items="messages" v-slot="{ item: message }" direction="up" reversed> + <XMessage :message="message" :is-group="group != null" :key="message.id"/> + </XList> + </div> + <footer> + <transition name="fade"> + <div class="new-message" v-show="showIndicator"> + <button class="_buttonPrimary" @click="onIndicatorClick"><i><Fa :icon="faArrowCircleDown"/></i>{{ $t('newMessageExists') }}</button> + </div> + </transition> + <XForm v-if="!fetching" :user="user" :group="group" ref="form"/> + </footer> </div> - <footer> - <transition name="fade"> - <div class="new-message" v-show="showIndicator"> - <button class="_buttonPrimary" @click="onIndicatorClick"><i><fa :icon="faArrowCircleDown"/></i>{{ $t('newMessageExists') }}</button> - </div> - </transition> - <x-form v-if="!fetching" :user="user" :group="group" ref="form"/> - </footer> </div> </template> <script lang="ts"> -import Vue from 'vue'; -import { faArrowCircleDown, faFlag, faUsers, faInfoCircle } from '@fortawesome/free-solid-svg-icons'; -import XList from '../../components/date-separated-list.vue'; +import { computed, defineComponent } from 'vue'; +import { faArrowCircleDown, faFlag, faUsers, faInfoCircle, faEllipsisH, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'; +import { faWindowMaximize } from '@fortawesome/free-regular-svg-icons'; +import XList from '@/components/date-separated-list.vue'; import XMessage from './messaging-room.message.vue'; import XForm from './messaging-room.form.vue'; import parseAcct from '../../../misc/acct/parse'; -import { isBottom, onScrollBottom } from '../../scripts/scroll'; +import { isBottom, onScrollBottom, scroll } from '@/scripts/scroll'; +import * as os from '@/os'; +import { popout } from '@/scripts/popout'; -export default Vue.extend({ +const Component = defineComponent({ components: { XMessage, XForm, XList, }, + inject: ['inWindow'], + + props: { + userAcct: { + type: String, + required: false, + }, + groupId: { + type: String, + required: false, + }, + }, + data() { return { + INFO: computed(() => !this.fetching ? this.user ? { + header: [{ + userName: this.user, + avatar: this.user, + }], + action: { + icon: faEllipsisH, + handler: this.menu, + }, + } : { + header: [{ + title: this.group.name, + icon: faUsers + }], + action: { + icon: faEllipsisH, + handler: this.menu, + }, + } : null), fetching: true, user: null, group: null, @@ -68,7 +96,7 @@ export default Vue.extend({ && this.existMoreMessages && this.fetchMoreMessages() ), - faArrowCircleDown, faFlag, faUsers, faInfoCircle + faArrowCircleDown, faFlag, faInfoCircle }; }, @@ -79,7 +107,8 @@ export default Vue.extend({ }, watch: { - $route: 'fetch' + userAcct: 'fetch', + groupId: 'fetch', }, mounted() { @@ -89,7 +118,7 @@ export default Vue.extend({ } }, - beforeDestroy() { + beforeUnmount() { this.connection.dispose(); document.removeEventListener('visibilitychange', this.onVisibilitychange); @@ -100,15 +129,15 @@ export default Vue.extend({ methods: { async fetch() { this.fetching = true; - if (this.$route.params.user) { - const user = await this.$root.api('users/show', parseAcct(this.$route.params.user)); + if (this.userAcct) { + const user = await os.api('users/show', parseAcct(this.userAcct)); this.user = user; } else { - const group = await this.$root.api('users/groups/show', { groupId: this.$route.params.group }); + const group = await os.api('users/groups/show', { groupId: this.groupId }); this.group = group; } - this.connection = this.$root.stream.connectToChannel('messaging', { + this.connection = os.stream.connectToChannel('messaging', { otherparty: this.user ? this.user.id : undefined, group: this.group ? this.group.id : undefined, }); @@ -131,7 +160,7 @@ export default Vue.extend({ onDragover(e) { const isFile = e.dataTransfer.items[0].kind == 'file'; - const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; if (isFile || isDriveFile) { e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; @@ -146,7 +175,7 @@ export default Vue.extend({ this.form.upload(e.dataTransfer.files[0]); return; } else if (e.dataTransfer.files.length > 1) { - this.$root.dialog({ + os.dialog({ type: 'error', text: this.$t('onlyOneFileCanBeAttached') }); @@ -154,7 +183,7 @@ export default Vue.extend({ } //#region ドライブのファイル - const driveFile = e.dataTransfer.getData('mk_drive_file'); + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); if (driveFile != null && driveFile != '') { const file = JSON.parse(driveFile); this.form.file = file; @@ -166,7 +195,7 @@ export default Vue.extend({ return new Promise((resolve, reject) => { const max = this.existMoreMessages ? 20 : 10; - this.$root.api('messaging/messages', { + os.api('messaging/messages', { userId: this.user ? this.user.id : undefined, groupId: this.group ? this.group.id : undefined, limit: max + 1, @@ -193,7 +222,7 @@ export default Vue.extend({ }, onMessage(message) { - this.$root.sound('chat'); + os.sound('chat'); const _isBottom = isBottom(this.$el, 64); @@ -248,7 +277,7 @@ export default Vue.extend({ }, scrollToBottom() { - window.scroll(0, document.body.offsetHeight); + scroll(this.$el, this.$el.offsetHeight); }, onIndicatorClick() { @@ -279,17 +308,36 @@ export default Vue.extend({ }); } } + }, + + menu(ev) { + const url = this.groupId ? `/my/messaging/group/${this.groupId}` : `/my/messaging/${this.userAcct}`; + + os.modalMenu([this.inWindow ? undefined : { + text: this.$t('openInWindow'), + icon: faWindowMaximize, + action: () => { + os.pageWindow(url, Component, this.$props); + this.$router.back(); + }, + }, this.inWindow ? undefined : { + text: this.$t('popout'), + icon: faExternalLinkAlt, + action: () => { + popout(url); + this.$router.back(); + }, + }], ev.currentTarget || ev.target); } } }); + +export default Component; </script> <style lang="scss" scoped> .mk-messaging-room { - > .body { - width: 100%; - > .empty { width: 100%; margin: 0; @@ -344,7 +392,7 @@ export default Vue.extend({ } > .messages { - > ::v-deep * { + > ::v-deep(*) { margin-bottom: 16px; } } @@ -384,7 +432,7 @@ export default Vue.extend({ transition: opacity 0.1s; } -.fade-enter, .fade-leave-to { +.fade-enter-from, .fade-leave-to { transition: opacity 0.5s; opacity: 0; } diff --git a/src/client/pages/miauth.vue b/src/client/pages/miauth.vue index 25170725da..2de058d0dd 100644 --- a/src/client/pages/miauth.vue +++ b/src/client/pages/miauth.vue @@ -1,49 +1,48 @@ <template> <div v-if="$store.getters.isSignedIn"> - <div class="waiting _card _vMargin" v-if="state == 'waiting'"> + <div class="waiting _section" v-if="state == 'waiting'"> <div class="_content"> - <mk-loading/> + <MkLoading/> </div> </div> - <div class="denied _card _vMargin" v-if="state == 'denied'"> + <div class="denied _section" v-if="state == 'denied'"> <div class="_content"> <p>{{ $t('_auth.denied') }}</p> </div> </div> - <div class="accepted _card _vMargin" v-else-if="state == 'accepted'"> + <div class="accepted _section" v-else-if="state == 'accepted'"> <div class="_content"> - <p v-if="callback">{{ $t('_auth.callback') }}<mk-ellipsis/></p> + <p v-if="callback">{{ $t('_auth.callback') }}<MkEllipsis/></p> <p v-else>{{ $t('_auth.pleaseGoBack') }}</p> </div> </div> - <div class="_card _vMargin" v-else> + <div class="_section" v-else> <div class="_title" v-if="name">{{ $t('_auth.shareAccess', { name: name }) }}</div> <div class="_title" v-else>{{ $t('_auth.shareAccessAsk') }}</div> <div class="_content"> <p>{{ $t('_auth.permissionAsk') }}</p> <ul> - <template v-for="p in permission"> - <li :key="p">{{ $t(`_permissions.${p}`) }}</li> - </template> + <li v-for="p in permission" :key="p">{{ $t(`_permissions.${p}`) }}</li> </ul> </div> <div class="_footer"> - <mk-button @click="deny" inline>{{ $t('cancel') }}</mk-button> - <mk-button @click="accept" inline primary>{{ $t('accept') }}</mk-button> + <MkButton @click="deny" inline>{{ $t('cancel') }}</MkButton> + <MkButton @click="accept" inline primary>{{ $t('accept') }}</MkButton> </div> </div> </div> <div class="signin" v-else> - <mk-signin @login="onLogin"/> + <MkSignin @login="onLogin"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; -import MkSignin from '../components/signin.vue'; -import MkButton from '../components/ui/button.vue'; +import { defineComponent } from 'vue'; +import MkSignin from '@/components/signin.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkSignin, MkButton, @@ -73,7 +72,7 @@ export default Vue.extend({ methods: { async accept() { this.state = 'waiting'; - await this.$root.api('miauth/gen-token', { + await os.api('miauth/gen-token', { session: this.session, name: this.name, iconUrl: this.icon, diff --git a/src/client/pages/my-antennas/index.antenna.vue b/src/client/pages/my-antennas/index.antenna.vue index 6435e4fc9a..509600590a 100644 --- a/src/client/pages/my-antennas/index.antenna.vue +++ b/src/client/pages/my-antennas/index.antenna.vue @@ -2,61 +2,61 @@ <div class="shaynizk _card"> <div class="_title" v-if="antenna.name">{{ antenna.name }}</div> <div class="_content body"> - <mk-input v-model="name"> + <MkInput v-model:value="name"> <span>{{ $t('name') }}</span> - </mk-input> - <mk-select v-model="src"> + </MkInput> + <MkSelect v-model:value="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> <option value="group">{{ $t('_antennaSources.userGroup') }}</option> - </mk-select> - <mk-select v-model="userListId" v-if="src === 'list'"> + </MkSelect> + <MkSelect v-model:value="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-select v-model="userGroupId" v-else-if="src === 'group'"> + </MkSelect> + <MkSelect v-model:value="userGroupId" v-else-if="src === 'group'"> <template #label>{{ $t('userGroup') }}</template> <option v-for="group in userGroups" :value="group.id" :key="group.id">{{ group.name }}</option> - </mk-select> - <mk-textarea v-model="users" v-else-if="src === 'users'"> + </MkSelect> + <MkTextarea v-model:value="users" v-else-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"> + </MkTextarea> + <MkSwitch v-model:value="withReplies">{{ $t('withReplies') }}</MkSwitch> + <MkTextarea v-model:value="keywords"> <span>{{ $t('antennaKeywords') }}</span> <template #desc>{{ $t('antennaKeywordsDescription') }}</template> - </mk-textarea> - <mk-textarea v-model="excludeKeywords"> + </MkTextarea> + <MkTextarea v-model:value="excludeKeywords"> <span>{{ $t('antennaExcludeKeywords') }}</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> + </MkTextarea> + <MkSwitch v-model:value="caseSensitive">{{ $t('caseSensitive') }}</MkSwitch> + <MkSwitch v-model:value="withFile">{{ $t('withFileAntenna') }}</MkSwitch> + <MkSwitch v-model:value="notify">{{ $t('notifyAntenna') }}</MkSwitch> </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> + <MkButton inline @click="saveAntenna()" primary><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> + <MkButton inline @click="deleteAntenna()" v-if="antenna.id != null"><Fa :icon="faTrash"/> {{ $t('delete') }}</MkButton> </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faSave, faTrash } from '@fortawesome/free-solid-svg-icons'; -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 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 getAcct from '../../../misc/acct/render'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkButton, MkInput, MkTextarea, MkSelect, MkSwitch }, @@ -90,12 +90,12 @@ export default Vue.extend({ watch: { async src() { if (this.src === 'list' && this.userLists === null) { - this.userLists = await this.$root.api('users/lists/list'); + this.userLists = await os.api('users/lists/list'); } if (this.src === 'group' && this.userGroups === null) { - const groups1 = await this.$root.api('users/groups/owned'); - const groups2 = await this.$root.api('users/groups/joined'); + const groups1 = await os.api('users/groups/owned'); + const groups2 = await os.api('users/groups/joined'); this.userGroups = [...groups1, ...groups2]; } @@ -119,7 +119,7 @@ export default Vue.extend({ methods: { async saveAntenna() { if (this.antenna.id == null) { - await this.$root.api('antennas/create', { + await os.api('antennas/create', { name: this.name, src: this.src, userListId: this.userListId, @@ -134,7 +134,7 @@ export default Vue.extend({ }); this.$emit('created'); } else { - await this.$root.api('antennas/update', { + await os.api('antennas/update', { antennaId: this.antenna.id, name: this.name, src: this.src, @@ -150,33 +150,27 @@ export default Vue.extend({ }); } - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); }, async deleteAntenna() { - const { canceled } = await this.$root.dialog({ + const { canceled } = await os.dialog({ type: 'warning', text: this.$t('removeAreYouSure', { x: this.antenna.name }), showCancelButton: true }); if (canceled) return; - await this.$root.api('antennas/delete', { + await os.api('antennas/delete', { antennaId: this.antenna.id, }); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); this.$emit('deleted'); }, addUser() { - this.$root.new(MkUserSelect, {}).$once('selected', user => { + os.selectUser().then(user => { this.users = this.users.trim(); this.users += '\n@' + getAcct(user); this.users = this.users.trim(); diff --git a/src/client/pages/my-antennas/index.vue b/src/client/pages/my-antennas/index.vue index a5f6076ebf..08e4d43c26 100644 --- a/src/client/pages/my-antennas/index.vue +++ b/src/client/pages/my-antennas/index.vue @@ -1,32 +1,25 @@ <template> -<div class="ieepwinx"> - <portal to="icon"><fa :icon="faSatellite"/></portal> - <portal to="title">{{ $t('manageAntennas') }}</portal> +<div class="ieepwinx _section"> + <MkButton @click="create" primary class="add"><Fa :icon="faPlus"/> {{ $t('add') }}</MkButton> - <mk-button @click="create" primary class="add"><fa :icon="faPlus"/> {{ $t('add') }}</mk-button> + <div class="_content"> + <XAntenna v-if="draft" :antenna="draft" @created="onAntennaCreated" style="margin-bottom: var(--margin);"/> - <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" :antenna="antenna" @created="onAntennaDeleted"/> - </mk-pagination> + <MkPagination :pagination="pagination" #default="{items}" class="antennas" ref="list"> + <XAntenna v-for="(antenna, i) in items" :key="antenna.id" :antenna="antenna" @created="onAntennaDeleted"/> + </MkPagination> + </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } 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 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, - }; - }, - +export default defineComponent({ components: { MkPagination, MkButton, @@ -35,6 +28,16 @@ export default Vue.extend({ data() { return { + INFO: { + header: [{ + title: this.$t('manageAntennas'), + icon: faSatellite + }], + action: { + icon: faPlus, + handler: this.create + } + }, pagination: { endpoint: 'antennas/list', limit: 10, diff --git a/src/client/pages/my-groups/group.vue b/src/client/pages/my-groups/group.vue index 5ac6db8e98..008c71d1fa 100644 --- a/src/client/pages/my-groups/group.vue +++ b/src/client/pages/my-groups/group.vue @@ -1,63 +1,58 @@ <template> <div class="mk-group-page"> - <portal to="icon"><fa :icon="faUsers"/></portal> - <portal to="title">{{ group.name }}</portal> - <transition name="zoom" mode="out-in"> - <div v-if="group" class="_card _vMargin"> + <div v-if="group" class="_section"> <div class="_content"> - <mk-button inline @click="renameGroup()">{{ $t('rename') }}</mk-button> - <mk-button inline @click="transfer()">{{ $t('transfer') }}</mk-button> - <mk-button inline @click="deleteGroup()">{{ $t('delete') }}</mk-button> + <MkButton inline @click="invite()">{{ $t('invite') }}</MkButton> + <MkButton inline @click="renameGroup()">{{ $t('rename') }}</MkButton> + <MkButton inline @click="transfer()">{{ $t('transfer') }}</MkButton> + <MkButton inline @click="deleteGroup()">{{ $t('delete') }}</MkButton> </div> </div> </transition> <transition name="zoom" mode="out-in"> - <div v-if="group" class="_card members _vMargin"> + <div v-if="group" class="_section members _vMargin"> <div class="_title">{{ $t('members') }}</div> <div class="_content"> <div class="users"> - <div class="user" v-for="user in users" :key="user.id"> - <mk-avatar :user="user" class="avatar"/> + <div class="user _panel" v-for="user in users" :key="user.id"> + <MkAvatar :user="user" class="avatar"/> <div class="body"> - <mk-user-name :user="user" class="name"/> - <mk-acct :user="user" class="acct"/> + <MkUserName :user="user" class="name"/> + <MkAcct :user="user" class="acct"/> </div> <div class="action"> - <button class="_button" @click="removeUser(user)"><fa :icon="faTimes"/></button> + <button class="_button" @click="removeUser(user)"><Fa :icon="faTimes"/></button> </div> </div> </div> </div> - <div class="_footer"> - <mk-button inline @click="invite()">{{ $t('invite') }}</mk-button> - </div> </div> </transition> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { computed, defineComponent } from 'vue'; import { faTimes, faUsers } from '@fortawesome/free-solid-svg-icons'; -import Progress from '../../scripts/loading'; -import MkButton from '../../components/ui/button.vue'; -import MkUserSelect from '../../components/user-select.vue'; - -export default Vue.extend({ - metaInfo() { - return { - title: this.group ? `${this.group.name} | ${this.$t('manageGroups')}` : this.$t('manageGroups') - }; - }, +import Progress from '@/scripts/loading'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +export default defineComponent({ components: { MkButton }, data() { return { + INFO: computed(() => this.group ? { + header: [{ + title: this.group.name, + icon: faUsers, + }], + } : null), group: null, users: [], faTimes, faUsers @@ -75,11 +70,11 @@ export default Vue.extend({ methods: { fetch() { Progress.start(); - this.$root.api('users/groups/show', { + os.api('users/groups/show', { groupId: this.$route.params.group }).then(group => { this.group = group; - this.$root.api('users/show', { + os.api('users/show', { userIds: this.group.userIds }).then(users => { this.users = users; @@ -89,26 +84,16 @@ export default Vue.extend({ }, invite() { - this.$root.new(MkUserSelect, {}).$once('selected', user => { - this.$root.api('users/groups/invite', { + os.selectUser().then(user => { + os.apiWithDialog('users/groups/invite', { groupId: this.group.id, userId: user.id - }).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); }); }); }, removeUser(user) { - this.$root.api('users/groups/pull', { + os.api('users/groups/pull', { groupId: this.group.id, userId: user.id }).then(() => { @@ -117,7 +102,7 @@ export default Vue.extend({ }, async renameGroup() { - const { canceled, result: name } = await this.$root.dialog({ + const { canceled, result: name } = await os.dialog({ title: this.$t('groupName'), input: { default: this.group.name @@ -125,7 +110,7 @@ export default Vue.extend({ }); if (canceled) return; - await this.$root.api('users/groups/update', { + await os.api('users/groups/update', { groupId: this.group.id, name: name }); @@ -134,39 +119,25 @@ export default Vue.extend({ }, transfer() { - this.$root.new(MkUserSelect, {}).$once('selected', user => { - this.$root.api('users/groups/transfer', { + os.selectUser().then(user => { + os.apiWithDialog('users/groups/transfer', { groupId: this.group.id, userId: user.id - }).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); }); }); }, async deleteGroup() { - const { canceled } = await this.$root.dialog({ + const { canceled } = await os.dialog({ type: 'warning', text: this.$t('removeAreYouSure', { x: this.group.name }), showCancelButton: true }); if (canceled) return; - await this.$root.api('users/groups/delete', { + await os.apiWithDialog('users/groups/delete', { groupId: this.group.id }); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); this.$router.push('/my/groups'); } } @@ -177,13 +148,11 @@ export default Vue.extend({ .mk-group-page { > .members { > ._content { - max-height: 400px; - overflow: auto; - > .users { > .user { display: flex; align-items: center; + padding: 16px; > .avatar { width: 50px; diff --git a/src/client/pages/my-groups/index.vue b/src/client/pages/my-groups/index.vue index 30d7dabbfb..f05226faaf 100644 --- a/src/client/pages/my-groups/index.vue +++ b/src/client/pages/my-groups/index.vue @@ -1,70 +1,74 @@ <template> <div class=""> - <portal to="icon"><fa :icon="faUsers"/></portal> - <portal to="title">{{ $t('groups') }}</portal> + <div class="_section" style="padding: 0;"> + <MkTab v-model:value="tab" :items="[{ label: $t('ownedGroups'), value: 'owned' }, { label: $t('joinedGroups'), value: 'joined' }, { label: $t('invites'), icon: faEnvelopeOpenText, value: 'invites' }]"/> + </div> - <mk-button @click="create" primary style="margin: 0 auto var(--margin) auto;"><fa :icon="faPlus"/> {{ $t('createGroup') }}</mk-button> + <div class="_section"> + <div class="_content" v-if="tab === 'owned'"> + <MkButton @click="create" primary style="margin: 0 auto var(--margin) auto;"><Fa :icon="faPlus"/> {{ $t('createGroup') }}</MkButton> - <mk-container :body-togglable="true"> - <template #header><fa :icon="faUsers"/> {{ $t('ownedGroups') }}</template> - <mk-pagination :pagination="ownedPagination" #default="{items}" ref="owned"> - <div class="_card" v-for="group in items" :key="group.id"> - <div class="_title"><router-link :to="`/my/groups/${ group.id }`" class="_link">{{ group.name }}</router-link></div> - <div class="_content"><mk-avatars :user-ids="group.userIds"/></div> - </div> - </mk-pagination> - </mk-container> - - <mk-container :body-togglable="true"> - <template #header><fa :icon="faEnvelopeOpenText"/> {{ $t('invites') }}</template> - <mk-pagination :pagination="invitationPagination" #default="{items}" ref="invitations"> - <div class="_card" v-for="invitation in items" :key="invitation.id"> - <div class="_title">{{ invitation.group.name }}</div> - <div class="_content"><mk-avatars :user-ids="invitation.group.userIds"/></div> - <div class="_footer"> - <mk-button @click="acceptInvite(invitation)" primary inline><fa :icon="faCheck"/> {{ $t('accept') }}</mk-button> - <mk-button @click="rejectInvite(invitation)" primary inline><fa :icon="faBan"/> {{ $t('reject') }}</mk-button> + <MkPagination :pagination="ownedPagination" #default="{items}" ref="owned"> + <div class="_card" v-for="group in items" :key="group.id"> + <div class="_title"><router-link :to="`/my/groups/${ group.id }`" class="_link">{{ group.name }}</router-link></div> + <div class="_content"><MkAvatars :user-ids="group.userIds"/></div> </div> - </div> - </mk-pagination> - </mk-container> + </MkPagination> + </div> - <mk-container :body-togglable="true"> - <template #header><fa :icon="faUsers"/> {{ $t('joinedGroups') }}</template> - <mk-pagination :pagination="joinedPagination" #default="{items}" ref="joined"> - <div class="_card" v-for="group in items" :key="group.id"> - <div class="_title">{{ group.name }}</div> - <div class="_content"><mk-avatars :user-ids="group.userIds"/></div> - </div> - </mk-pagination> - </mk-container> + <div class="_content" v-else-if="tab === 'joined'"> + <MkPagination :pagination="joinedPagination" #default="{items}" ref="joined"> + <div class="_card" v-for="group in items" :key="group.id"> + <div class="_title">{{ group.name }}</div> + <div class="_content"><MkAvatars :user-ids="group.userIds"/></div> + </div> + </MkPagination> + </div> + + <div class="_content" v-else-if="tab === 'invites'"> + <MkPagination :pagination="invitationPagination" #default="{items}" ref="invitations"> + <div class="_card" v-for="invitation in items" :key="invitation.id"> + <div class="_title">{{ invitation.group.name }}</div> + <div class="_content"><MkAvatars :user-ids="invitation.group.userIds"/></div> + <div class="_footer"> + <MkButton @click="acceptInvite(invitation)" primary inline><Fa :icon="faCheck"/> {{ $t('accept') }}</MkButton> + <MkButton @click="rejectInvite(invitation)" primary inline><Fa :icon="faBan"/> {{ $t('reject') }}</MkButton> + </div> + </div> + </MkPagination> + </div> + </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faUsers, faPlus, faEnvelopeOpenText } from '@fortawesome/free-solid-svg-icons'; -import MkPagination from '../../components/ui/pagination.vue'; -import MkButton from '../../components/ui/button.vue'; -import MkContainer from '../../components/ui/container.vue'; -import MkAvatars from '../../components/avatars.vue'; - -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('groups') as string, - }; - }, +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkAvatars from '@/components/avatars.vue'; +import MkTab from '@/components/tab.vue'; +import * as os from '@/os'; +export default defineComponent({ components: { MkPagination, MkButton, MkContainer, + MkTab, MkAvatars, }, data() { return { + INFO: { + header: [{ + title: this.$t('groups'), + icon: faUsers + }], + }, + tab: 'owned', ownedPagination: { endpoint: 'users/groups/owned', limit: 10, @@ -83,32 +87,26 @@ export default Vue.extend({ methods: { async create() { - const { canceled, result: name } = await this.$root.dialog({ + const { canceled, result: name } = await os.dialog({ title: this.$t('groupName'), input: true }); if (canceled) return; - await this.$root.api('users/groups/create', { name: name }); + await os.api('users/groups/create', { name: name }); this.$refs.owned.reload(); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); }, acceptInvite(invitation) { - this.$root.api('users/groups/invitations/accept', { + os.api('users/groups/invitations/accept', { invitationId: invitation.id }).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); this.$refs.invitations.reload(); this.$refs.joined.reload(); }); }, rejectInvite(invitation) { - this.$root.api('users/groups/invitations/reject', { + os.api('users/groups/invitations/reject', { invitationId: invitation.id }).then(() => { this.$refs.invitations.reload(); diff --git a/src/client/pages/my-lists/index.vue b/src/client/pages/my-lists/index.vue index c3f6d9c774..5e29436ede 100644 --- a/src/client/pages/my-lists/index.vue +++ b/src/client/pages/my-lists/index.vue @@ -1,31 +1,23 @@ <template> -<div class="qkcjvfiv"> - <portal to="icon"><fa :icon="faListUl"/></portal> - <portal to="title">{{ $t('manageLists') }}</portal> +<div class="qkcjvfiv _section"> + <MkButton @click="create" primary class="add"><Fa :icon="faPlus"/> {{ $t('createList') }}</MkButton> - <mk-button @click="create" primary class="add"><fa :icon="faPlus"/> {{ $t('createList') }}</mk-button> - - <mk-pagination :pagination="pagination" #default="{items}" class="lists" ref="list"> + <MkPagination :pagination="pagination" #default="{items}" class="lists _content" ref="list"> <div class="list _panel" v-for="(list, i) in items" :key="list.id"> <router-link :to="`/my/lists/${ list.id }`">{{ list.name }}</router-link> </div> - </mk-pagination> + </MkPagination> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } 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, - }; - }, +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +export default defineComponent({ components: { MkPagination, MkButton, @@ -33,6 +25,16 @@ export default Vue.extend({ data() { return { + INFO: { + header: [{ + title: this.$t('manageLists'), + icon: faListUl + }], + action: { + icon: faPlus, + handler: this.create + } + }, pagination: { endpoint: 'users/lists/list', limit: 10, @@ -43,17 +45,14 @@ export default Vue.extend({ methods: { async create() { - const { canceled, result: name } = await this.$root.dialog({ + const { canceled, result: name } = await os.dialog({ title: this.$t('enterListName'), input: true }); if (canceled) return; - await this.$root.api('users/lists/create', { name: name }); + await os.api('users/lists/create', { name: name }); this.$refs.list.reload(); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); }, } }); diff --git a/src/client/pages/my-lists/list.vue b/src/client/pages/my-lists/list.vue index a418bdded5..4d965b06b3 100644 --- a/src/client/pages/my-lists/list.vue +++ b/src/client/pages/my-lists/list.vue @@ -1,62 +1,57 @@ <template> <div class="mk-list-page"> - <portal to="icon"><fa :icon="faListUl"/></portal> - <portal to="title">{{ list.name }}</portal> - <transition name="zoom" mode="out-in"> - <div v-if="list" class="_card _vMargin"> + <div v-if="list" class="_section"> <div class="_content"> - <mk-button inline @click="renameList()">{{ $t('rename') }}</mk-button> - <mk-button inline @click="deleteList()">{{ $t('delete') }}</mk-button> + <MkButton inline @click="addUser()">{{ $t('addUser') }}</MkButton> + <MkButton inline @click="renameList()">{{ $t('rename') }}</MkButton> + <MkButton inline @click="deleteList()">{{ $t('delete') }}</MkButton> </div> </div> </transition> <transition name="zoom" mode="out-in"> - <div v-if="list" class="_card members _vMargin"> + <div v-if="list" class="_section members _vMargin"> <div class="_title">{{ $t('members') }}</div> <div class="_content"> <div class="users"> - <div class="user" v-for="user in users" :key="user.id"> - <mk-avatar :user="user" class="avatar"/> + <div class="user _panel" v-for="user in users" :key="user.id"> + <MkAvatar :user="user" class="avatar"/> <div class="body"> - <mk-user-name :user="user" class="name"/> - <mk-acct :user="user" class="acct"/> + <MkUserName :user="user" class="name"/> + <MkAcct :user="user" class="acct"/> </div> <div class="action"> - <button class="_button" @click="removeUser(user)"><fa :icon="faTimes"/></button> + <button class="_button" @click="removeUser(user)"><Fa :icon="faTimes"/></button> </div> </div> </div> </div> - <div class="_footer"> - <mk-button inline @click="addUser()">{{ $t('addUser') }}</mk-button> - </div> </div> </transition> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { computed, defineComponent } from 'vue'; import { faTimes, faListUl } from '@fortawesome/free-solid-svg-icons'; -import Progress from '../../scripts/loading'; -import MkButton from '../../components/ui/button.vue'; -import MkUserSelect from '../../components/user-select.vue'; - -export default Vue.extend({ - metaInfo() { - return { - title: this.list ? `${this.list.name} | ${this.$t('manageLists')}` : this.$t('manageLists') - }; - }, +import Progress from '@/scripts/loading'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +export default defineComponent({ components: { MkButton }, data() { return { + INFO: computed(() => this.list ? { + header: [{ + title: this.list.name, + icon: faListUl, + }], + } : null), list: null, users: [], faTimes, faListUl @@ -74,11 +69,11 @@ export default Vue.extend({ methods: { fetch() { Progress.start(); - this.$root.api('users/lists/show', { + os.api('users/lists/show', { listId: this.$route.params.list }).then(list => { this.list = list; - this.$root.api('users/show', { + os.api('users/show', { userIds: this.list.userIds }).then(users => { this.users = users; @@ -88,27 +83,18 @@ export default Vue.extend({ }, addUser() { - this.$root.new(MkUserSelect, {}).$once('selected', user => { - this.$root.api('users/lists/push', { + os.selectUser().then(user => { + os.apiWithDialog('users/lists/push', { listId: this.list.id, userId: user.id }).then(() => { this.users.push(user); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }).catch(e => { - this.$root.dialog({ - type: 'error', - text: e - }); }); }); }, removeUser(user) { - this.$root.api('users/lists/pull', { + os.api('users/lists/pull', { listId: this.list.id, userId: user.id }).then(() => { @@ -117,7 +103,7 @@ export default Vue.extend({ }, async renameList() { - const { canceled, result: name } = await this.$root.dialog({ + const { canceled, result: name } = await os.dialog({ title: this.$t('enterListName'), input: { default: this.list.name @@ -125,7 +111,7 @@ export default Vue.extend({ }); if (canceled) return; - await this.$root.api('users/lists/update', { + await os.api('users/lists/update', { listId: this.list.id, name: name }); @@ -134,20 +120,17 @@ export default Vue.extend({ }, async deleteList() { - const { canceled } = await this.$root.dialog({ + const { canceled } = await os.dialog({ type: 'warning', text: this.$t('removeAreYouSure', { x: this.list.name }), showCancelButton: true }); if (canceled) return; - await this.$root.api('users/lists/delete', { + await os.api('users/lists/delete', { listId: this.list.id }); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); this.$router.push('/my/lists'); } } @@ -158,13 +141,11 @@ export default Vue.extend({ .mk-list-page { > .members { > ._content { - max-height: 400px; - overflow: auto; - > .users { > .user { display: flex; align-items: center; + padding: 16px; > .avatar { width: 50px; diff --git a/src/client/pages/my-settings/api.vue b/src/client/pages/my-settings/api.vue deleted file mode 100644 index 44f099ea1d..0000000000 --- a/src/client/pages/my-settings/api.vue +++ /dev/null @@ -1,58 +0,0 @@ -<template> -<section class="_card"> - <div class="_title"><fa :icon="faKey"/> API</div> - <div class="_content"> - <mk-button @click="generateToken">{{ $t('generateAccessToken') }}</mk-button> - <mk-button @click="regenerateToken"><fa :icon="faSyncAlt"/> {{ $t('regenerate') }}</mk-button> - </div> -</section> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faKey, faSyncAlt } from '@fortawesome/free-solid-svg-icons'; -import MkButton from '../../components/ui/button.vue'; -import MkInput from '../../components/ui/input.vue'; - -export default Vue.extend({ - components: { - MkButton, MkInput - }, - data() { - return { - faKey, faSyncAlt - }; - }, - methods: { - async generateToken() { - this.$root.new(await import('../../components/token-generate-window.vue').then(m => m.default), { - }).$on('ok', async ({ name, permissions }) => { - const { token } = await this.$root.api('miauth/gen-token', { - session: null, - name: name, - permission: permissions, - }); - - this.$root.dialog({ - type: 'success', - title: this.$t('token'), - text: token - }); - }); - }, - regenerateToken() { - this.$root.dialog({ - title: this.$t('password'), - input: { - type: 'password' - } - }).then(({ canceled, result: password }) => { - if (canceled) return; - this.$root.api('i/regenerate_token', { - password: password - }); - }); - }, - } -}); -</script> diff --git a/src/client/pages/my-settings/index.vue b/src/client/pages/my-settings/index.vue deleted file mode 100644 index ae4ad4dff5..0000000000 --- a/src/client/pages/my-settings/index.vue +++ /dev/null @@ -1,137 +0,0 @@ -<template> -<div> - <portal to="icon"><fa :icon="faCog"/></portal> - <portal to="title">{{ $t('accountSettings') }}</portal> - - <x-profile-setting class="_vMargin"/> - <x-privacy-setting class="_vMargin"/> - <x-reaction-setting class="_vMargin"/> - - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div> - <div class="_content"> - <mk-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch"> - {{ $t('autoNoteWatch') }}<template #desc>{{ $t('autoNoteWatchDescription') }}</template> - </mk-switch> - <mk-switch v-model="$store.state.i.injectFeaturedNote" @change="onChangeInjectFeaturedNote"> - {{ $t('showFeaturedNotesInTimeline') }} - </mk-switch> - </div> - <div class="_content"> - <mk-button @click="readAllNotifications">{{ $t('markAsReadAllNotifications') }}</mk-button> - <mk-button @click="readAllUnreadNotes">{{ $t('markAsReadAllUnreadNotes') }}</mk-button> - <mk-button @click="readAllMessagingMessages">{{ $t('markAsReadAllTalkMessages') }}</mk-button> - </div> - <div class="_content"> - <mk-button @click="configure">{{ $t('notificationSetting') }}</mk-button> - </div> - </section> - - <x-import-export class="_vMargin"/> - <x-drive class="_vMargin"/> - <x-mute-block class="_vMargin"/> - <x-word-mute class="_vMargin"/> - <x-security class="_vMargin"/> - <x-2fa class="_vMargin"/> - <x-integration class="_vMargin"/> - <x-api class="_vMargin"/> - - <router-link class="_panel _buttonPrimary" to="/my/apps" style="margin: var(--margin) auto;">{{ $t('installedApps') }}</router-link> - - <button class="_panel _buttonPrimary" @click="$root.signout()" style="margin: var(--margin) auto;">{{ $t('logout') }}</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 XReactionSetting from './reaction.vue'; -import XMuteBlock from './mute-block.vue'; -import XWordMute from './word-mute.vue'; -import XSecurity from './security.vue'; -import X2fa from './2fa.vue'; -import XIntegration from './integration.vue'; -import XApi from './api.vue'; -import MkButton from '../../components/ui/button.vue'; -import MkSwitch from '../../components/ui/switch.vue'; -import { notificationTypes } from '../../../types'; - -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('settings') as string - }; - }, - - components: { - XProfileSetting, - XPrivacySetting, - XImportExport, - XDrive, - XReactionSetting, - XMuteBlock, - XWordMute, - XSecurity, - X2fa, - XIntegration, - XApi, - MkButton, - MkSwitch, - }, - - data() { - return { - faCog - } - }, - - methods: { - onChangeAutoWatch(v) { - this.$root.api('i/update', { - autoWatch: v - }); - }, - - onChangeInjectFeaturedNote(v) { - this.$root.api('i/update', { - injectFeaturedNote: 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'); - }, - - async configure() { - const includingTypes = notificationTypes.filter(x => !this.$store.state.i.mutingNotificationTypes.includes(x)); - this.$root.new(await import('../../components/notification-setting-window.vue').then(m => m.default), { - includingTypes, - showGlobalToggle: false, - }).$on('ok', async ({ includingTypes: value }: any) => { - await this.$root.api('i/update', { - mutingNotificationTypes: notificationTypes.filter(x => !value.includes(x)), - }).then(i => { - this.$store.state.i.mutingNotificationTypes = i.mutingNotificationTypes; - }).catch(err => { - this.$root.dialog({ - type: 'error', - text: err.message - }); - }); - }); - } - } -}); -</script> diff --git a/src/client/pages/my-settings/mute-block.vue b/src/client/pages/my-settings/mute-block.vue deleted file mode 100644 index 8eb43a6e29..0000000000 --- a/src/client/pages/my-settings/mute-block.vue +++ /dev/null @@ -1,73 +0,0 @@ -<template> -<section class="rrfwjxfl _card"> - <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"> - <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"> - <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'; - -export default Vue.extend({ - components: { - MkPagination, - }, - - data() { - return { - mutingPagination: { - endpoint: 'mute/list', - limit: 10, - }, - blockingPagination: { - endpoint: 'blocking/list', - limit: 10, - }, - faBan - } - }, -}); -</script> - -<style lang="scss" scoped> -.rrfwjxfl { - > ._content { - max-height: 350px; - overflow: auto; - - > .muting, - > .blocking { - > .empty { - opacity: 0.5 !important; - } - } - } -} -</style> diff --git a/src/client/pages/my-settings/privacy.vue b/src/client/pages/my-settings/privacy.vue deleted file mode 100644 index 527ac9ea37..0000000000 --- a/src/client/pages/my-settings/privacy.vue +++ /dev/null @@ -1,73 +0,0 @@ -<template> -<section class="_card"> - <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" v-if="isLocked" @change="save()">{{ $t('autoAcceptFollowed') }}</mk-switch> - </div> - <div class="_content"> - <mk-switch v-model="rememberNoteVisibility" @change="save()">{{ $t('rememberNoteVisibility') }}</mk-switch> - <mk-select v-model="defaultNoteVisibility" style="margin-bottom: 8px;" v-if="!rememberNoteVisibility"> - <template #label>{{ $t('defaultNoteVisibility') }}</template> - <option value="public">{{ $t('_visibility.public') }}</option> - <option value="home">{{ $t('_visibility.home') }}</option> - <option value="followers">{{ $t('_visibility.followers') }}</option> - <option value="specified">{{ $t('_visibility.specified') }}</option> - </mk-select> - <mk-switch v-model="defaultNoteLocalOnly" v-if="!rememberNoteVisibility">{{ $t('_visibility.localOnly') }}</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'; - -export default Vue.extend({ - 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 }); } - }, - - defaultNoteLocalOnly: { - get() { return this.$store.state.settings.defaultNoteLocalOnly; }, - set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteLocalOnly', 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/my-settings/reaction.vue b/src/client/pages/my-settings/reaction.vue deleted file mode 100644 index ef4f6f6723..0000000000 --- a/src/client/pages/my-settings/reaction.vue +++ /dev/null @@ -1,84 +0,0 @@ -<template> -<section class="_card"> - <div class="_title"><fa :icon="faLaugh"/> {{ $t('reaction') }}</div> - <div class="_content"> - <mk-input v-model="reactions" style="font-family: 'Segoe UI Emoji', 'Noto Color Emoji', Roboto, HelveticaNeue, Arial, sans-serif"> - {{ $t('reaction') }}<template #desc>{{ $t('reactionSettingDescription') }} <button class="_textButton" @click="chooseEmoji">{{ $t('chooseEmoji') }}</button></template> - </mk-input> - <mk-button inline @click="setDefault"><fa :icon="faUndo"/> {{ $t('default') }}</mk-button> - </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 { faUndo } from '@fortawesome/free-solid-svg-icons'; -import MkInput from '../../components/ui/input.vue'; -import MkButton from '../../components/ui/button.vue'; -import MkReactionPicker from '../../components/reaction-picker.vue'; -import { emojiRegexWithCustom } from '../../../misc/emoji-regex'; -import { defaultSettings } from '../../store'; - -export default Vue.extend({ - components: { - MkInput, - MkButton, - }, - - data() { - return { - reactions: this.$store.state.settings.reactions.join(''), - changed: false, - faLaugh, faSave, faEye, faUndo - } - }, - - computed: { - splited(): any { - return this.reactions.match(emojiRegexWithCustom); - }, - }, - - watch: { - reactions() { - this.changed = true; - } - }, - - methods: { - save() { - this.$store.dispatch('settings/set', { key: 'reactions', value: this.splited }); - this.changed = false; - }, - - preview(ev) { - const picker = this.$root.new(MkReactionPicker, { - source: ev.currentTarget || ev.target, - reactions: this.splited, - showFocus: false, - }); - picker.$once('chosen', reaction => { - picker.close(); - }); - }, - - setDefault() { - this.reactions = defaultSettings.reactions.join(''); - }, - - async chooseEmoji(ev) { - const vm = this.$root.new(await import('../../components/emoji-picker.vue').then(m => m.default), { - source: ev.currentTarget || ev.target - }).$once('chosen', emoji => { - this.reactions += emoji; - vm.close(); - }); - } - } -}); -</script> diff --git a/src/client/pages/my-settings/security.vue b/src/client/pages/my-settings/security.vue deleted file mode 100644 index dc77ca12c5..0000000000 --- a/src/client/pages/my-settings/security.vue +++ /dev/null @@ -1,84 +0,0 @@ -<template> -<section class="_card"> - <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'; - -export default Vue.extend({ - 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/my-settings/word-mute.vue b/src/client/pages/my-settings/word-mute.vue deleted file mode 100644 index f9bb68cd10..0000000000 --- a/src/client/pages/my-settings/word-mute.vue +++ /dev/null @@ -1,81 +0,0 @@ -<template> -<section class="_card"> - <div class="_title"><fa :icon="faCommentSlash"/> {{ $t('wordMute') }}</div> - <div class="_content _noPad"> - <mk-tab v-model="tab" :items="[{ label: $t('_wordMute.soft'), value: 'soft' }, { label: $t('_wordMute.hard'), value: 'hard' }]"/> - </div> - <div class="_content" v-show="tab === 'soft'"> - <mk-info>{{ $t('_wordMute.softDescription') }}</mk-info> - <mk-textarea v-model="softMutedWords"> - <span>{{ $t('_wordMute.muteWords') }}</span> - <template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template> - </mk-textarea> - </div> - <div class="_content" v-show="tab === 'hard'"> - <mk-info>{{ $t('_wordMute.hardDescription') }}</mk-info> - <mk-textarea v-model="hardMutedWords" style="margin-bottom: 16px;"> - <span>{{ $t('_wordMute.muteWords') }}</span> - <template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template> - </mk-textarea> - <div v-if="hardWordMutedNotesCount != null" class="_caption">{{ $t('_wordMute.mutedNotes') }}: {{ hardWordMutedNotesCount | number }}</div> - </div> - <div class="_footer"> - <mk-button @click="save()" primary inline :disabled="!changed"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> - </div> -</section> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faCommentSlash, faSave } from '@fortawesome/free-solid-svg-icons'; -import MkButton from '../../components/ui/button.vue'; -import MkTextarea from '../../components/ui/textarea.vue'; -import MkTab from '../../components/tab.vue'; -import MkInfo from '../../components/ui/info.vue'; - -export default Vue.extend({ - components: { - MkButton, - MkTextarea, - MkTab, - MkInfo, - }, - - data() { - return { - tab: 'soft', - softMutedWords: '', - hardMutedWords: '', - hardWordMutedNotesCount: null, - changed: false, - faCommentSlash, faSave, - } - }, - - watch: { - softMutedWords() { - this.changed = true; - }, - hardMutedWords() { - this.changed = true; - }, - }, - - async created() { - this.softMutedWords = this.$store.state.settings.mutedWords.map(x => x.join(' ')).join('\n'); - this.hardMutedWords = this.$store.state.i.mutedWords.map(x => x.join(' ')).join('\n'); - - this.hardWordMutedNotesCount = (await this.$root.api('i/get-word-muted-notes-count', {})).count; - }, - - methods: { - async save() { - this.$store.dispatch('settings/set', { key: 'mutedWords', value: this.softMutedWords.trim().split('\n').map(x => x.trim().split(' ')) }); - await this.$root.api('i/update', { - mutedWords: this.hardMutedWords.trim().split('\n').map(x => x.trim().split(' ')), - }); - this.changed = false; - }, - } -}); -</script> diff --git a/src/client/pages/not-found.vue b/src/client/pages/not-found.vue index 5bc4d4589a..a90a6344e4 100644 --- a/src/client/pages/not-found.vue +++ b/src/client/pages/not-found.vue @@ -1,8 +1,5 @@ <template> <div class="ipledcug"> - <portal to="icon"><fa :icon="faExclamationTriangle"/></portal> - <portal to="title">{{ $t('notFound') }}</portal> - <div class="_fullinfo"> <img src="https://xn--931a.moe/assets/not-found.jpg" class="_ghost"/> <div>{{ $t('notFoundDescription') }}</div> @@ -11,19 +8,19 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; +import * as os from '@/os'; -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('notFound') as string - }; - }, - +export default defineComponent({ data() { return { - faExclamationTriangle + INFO: { + header: [{ + title: this.$t('notFound'), + icon: faExclamationTriangle + }] + }, } }, }); diff --git a/src/client/pages/note.vue b/src/client/pages/note.vue index 3f42516323..6a9167284f 100644 --- a/src/client/pages/note.vue +++ b/src/client/pages/note.vue @@ -1,53 +1,55 @@ <template> -<div class="mk-note-page"> - <portal to="avatar" v-if="note"><mk-avatar class="avatar" :user="note.user" :disable-preview="true"/></portal> - <portal to="title" v-if="note"> - <mfm - :text="$t('noteOf', { user: note.user.name || note.user.username })" - :plain="true" :nowrap="true" :custom-emojis="note.user.emojis" :is-note="false" - /> - </portal> +<div class="fcuexfpr"> + <div v-if="note" class="note"> + <div class="_section"> + <XNotes v-if="showNext" class="_content" :pagination="next"/> + <MkButton v-else-if="hasNext" class="load _content" @click="showNext = true"><Fa :icon="faChevronUp"/></MkButton> + </div> - <div v-if="note"> - <button class="_panel _button" v-if="hasNext && !showNext" @click="showNext = true" style="margin: 0 auto var(--margin) auto;"><fa :icon="faChevronUp"/></button> - <x-notes v-if="showNext" ref="next" :pagination="next"/> - <hr v-if="showNext"/> + <div class="_section"> + <div class="_content"> + <MkRemoteCaution v-if="note.user.host != null" :href="note.url || note.uri" style="margin-bottom: var(--margin)"/> + <XNote v-model:note="note" :key="note.id" :detail="true"/> + </div> + </div> - <mk-remote-caution v-if="note.user.host != null" :href="note.url || note.uri" style="margin-bottom: var(--margin)"/> - <x-note v-model="note" :key="note.id" :detail="true"/> - - <button class="_panel _button" v-if="hasPrev && !showPrev" @click="showPrev = true" style="margin: var(--margin) auto 0 auto;"><fa :icon="faChevronDown"/></button> - <hr v-if="showPrev"/> - <x-notes v-if="showPrev" ref="prev" :pagination="prev" style="margin-top: var(--margin);"/> + <div class="_section"> + <XNotes v-if="showPrev" class="_content" :pagination="prev"/> + <MkButton v-else-if="hasPrev" class="load _content" @click="showPrev = true"><Fa :icon="faChevronDown"/></MkButton> + </div> </div> <div v-if="error"> - <mk-error @retry="fetch()"/> + <MkError @retry="fetch()"/> </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { computed, defineComponent } from 'vue'; import { faChevronUp, faChevronDown } from '@fortawesome/free-solid-svg-icons'; -import Progress from '../scripts/loading'; -import XNote from '../components/note.vue'; -import XNotes from '../components/notes.vue'; -import MkRemoteCaution from '../components/remote-caution.vue'; +import Progress from '@/scripts/loading'; +import XNote from '@/components/note.vue'; +import XNotes from '@/components/notes.vue'; +import MkRemoteCaution from '@/components/remote-caution.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('note') as string - }; - }, +export default defineComponent({ components: { XNote, XNotes, MkRemoteCaution, + MkButton, }, data() { return { + INFO: computed(() => this.note ? { + header: [{ + title: this.$t('note'), + avatar: this.note.user, + }], + } : null), note: null, hasPrev: false, hasNext: false, @@ -83,16 +85,16 @@ export default Vue.extend({ methods: { fetch() { Progress.start(); - this.$root.api('notes/show', { + os.api('notes/show', { noteId: this.$route.params.note }).then(note => { Promise.all([ - this.$root.api('users/notes', { + os.api('users/notes', { userId: note.userId, untilId: note.id, limit: 1, }), - this.$root.api('users/notes', { + os.api('users/notes', { userId: note.userId, sinceId: note.id, limit: 1, @@ -111,3 +113,16 @@ export default Vue.extend({ } }); </script> + +<style lang="scss" scoped> +.fcuexfpr { + > .note { + > ._section { + > .load { + min-width: 0; + border-radius: 999px; + } + } + } +} +</style> diff --git a/src/client/pages/notifications.vue b/src/client/pages/notifications.vue index 49e67bc8f7..97ed36a750 100644 --- a/src/client/pages/notifications.vue +++ b/src/client/pages/notifications.vue @@ -1,31 +1,31 @@ <template> <div> - <portal to="icon"><fa :icon="faBell"/></portal> - <portal to="title">{{ $t('notifications') }}</portal> - <x-notifications @before="before" @after="after" page/> + <div class="_section"> + <XNotifications class="_content" @before="before" @after="after" page/> + </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faBell } from '@fortawesome/free-solid-svg-icons'; -import Progress from '../scripts/loading'; -import XNotifications from '../components/notifications.vue'; - -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('notifications') as string - }; - }, +import Progress from '@/scripts/loading'; +import XNotifications from '@/components/notifications.vue'; +import * as os from '@/os'; +export default defineComponent({ components: { XNotifications }, data() { return { - faBell + INFO: { + header: [{ + title: this.$t('notifications'), + icon: faBell + }] + }, }; }, 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 index 9821201666..7c65904574 100644 --- a/src/client/pages/page-editor/els/page-editor.el.button.vue +++ b/src/client/pages/page-editor/els/page-editor.el.button.vue @@ -1,24 +1,24 @@ <template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.button') }}</template> +<XContainer @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"> + <MkInput v-model:value="value.text"><span>{{ $t('_pages.blocks._button.text') }}</span></MkInput> + <MkSwitch v-model:value="value.primary"><span>{{ $t('_pages.blocks._button.colored') }}</span></MkSwitch> + <MkSelect v-model:value="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> <option value="callAiScript">{{ $t('_pages.blocks._button._action.callAiScript') }}</option> - </mk-select> + </MkSelect> <template v-if="value.action === 'dialog'"> - <mk-input v-model="value.content"><span>{{ $t('_pages.blocks._button._action._dialog.content') }}</span></mk-input> + <MkInput v-model:value="value.content"><span>{{ $t('_pages.blocks._button._action._dialog.content') }}</span></MkInput> </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"> + <MkInput v-model:value="value.event"><span>{{ $t('_pages.blocks._button._action._pushEvent.event') }}</span></MkInput> + <MkInput v-model:value="value.message"><span>{{ $t('_pages.blocks._button._action._pushEvent.message') }}</span></MkInput> + <MkSelect v-model:value="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 hpml.getVarsByType()" :value="v.name">{{ v.name }}</option> @@ -28,24 +28,25 @@ <optgroup :label="$t('_pages.script.enviromentVariables')"> <option v-for="v in hpml.getEnvVarsByType()" :value="v">{{ v }}</option> </optgroup> - </mk-select> + </MkSelect> </template> <template v-else-if="value.action === 'callAiScript'"> - <mk-input v-model="value.fn"><span>{{ $t('_pages.blocks._button._action._callAiScript.functionName') }}</span></mk-input> + <MkInput v-model:value="value.fn"><span>{{ $t('_pages.blocks._button._action._callAiScript.functionName') }}</span></MkInput> </template> </section> -</x-container> +</XContainer> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faBolt } from '@fortawesome/free-solid-svg-icons'; 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'; +import MkSelect from '@/components/ui/select.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XContainer, MkSelect, MkInput, MkSwitch }, @@ -66,14 +67,14 @@ export default Vue.extend({ }, 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); - if (this.value.fn == null) Vue.set(this.value, 'fn', null); + if (this.value.text == null) this.value.text = ''; + if (this.value.action == null) this.value.action = 'dialog'; + if (this.value.content == null) this.value.content = null; + if (this.value.event == null) this.value.event = null; + if (this.value.message == null) this.value.message = null; + if (this.value.primary == null) this.value.primary = false; + if (this.value.var == null) this.value.var = null; + if (this.value.fn == null) this.value.fn = null; }, }); </script> diff --git a/src/client/pages/page-editor/els/page-editor.el.canvas.vue b/src/client/pages/page-editor/els/page-editor.el.canvas.vue index a499207806..ff7e16064e 100644 --- a/src/client/pages/page-editor/els/page-editor.el.canvas.vue +++ b/src/client/pages/page-editor/els/page-editor.el.canvas.vue @@ -1,22 +1,23 @@ <template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faPaintBrush"/> {{ $t('_pages.blocks.canvas') }}</template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><Fa :icon="faPaintBrush"/> {{ $t('_pages.blocks.canvas') }}</template> <section style="padding: 0 16px 0 16px;"> - <mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._canvas.id') }}</span></mk-input> - <mk-input v-model="value.width" type="number"><span>{{ $t('_pages.blocks._canvas.width') }}</span><template #suffix>px</template></mk-input> - <mk-input v-model="value.height" type="number"><span>{{ $t('_pages.blocks._canvas.height') }}</span><template #suffix>px</template></mk-input> + <MkInput v-model:value="value.name"><template #prefix><Fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._canvas.id') }}</span></MkInput> + <MkInput v-model:value="value.width" type="number"><span>{{ $t('_pages.blocks._canvas.width') }}</span><template #suffix>px</template></MkInput> + <MkInput v-model:value="value.height" type="number"><span>{{ $t('_pages.blocks._canvas.height') }}</span><template #suffix>px</template></MkInput> </section> -</x-container> +</XContainer> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faPaintBrush, faMagic } from '@fortawesome/free-solid-svg-icons'; import XContainer from '../page-editor.container.vue'; -import MkInput from '../../../components/ui/input.vue'; +import MkInput from '@/components/ui/input.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XContainer, MkInput }, @@ -34,9 +35,9 @@ export default Vue.extend({ }, created() { - if (this.value.name == null) Vue.set(this.value, 'name', ''); - if (this.value.width == null) Vue.set(this.value, 'width', 300); - if (this.value.height == null) Vue.set(this.value, 'height', 200); + if (this.value.name == null) this.value.name = ''; + if (this.value.width == null) this.value.width = 300; + if (this.value.height == null) this.value.height = 200; }, }); </script> diff --git a/src/client/pages/page-editor/els/page-editor.el.counter.vue b/src/client/pages/page-editor/els/page-editor.el.counter.vue index f439f3e6ff..ae62c2fa83 100644 --- a/src/client/pages/page-editor/els/page-editor.el.counter.vue +++ b/src/client/pages/page-editor/els/page-editor.el.counter.vue @@ -1,22 +1,23 @@ <template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.counter') }}</template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><Fa :icon="faBolt"/> {{ $t('_pages.blocks.counter') }}</template> <section style="padding: 0 16px 0 16px;"> - <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> + <MkInput v-model:value="value.name"><template #prefix><Fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._counter.name') }}</span></MkInput> + <MkInput v-model:value="value.text"><span>{{ $t('_pages.blocks._counter.text') }}</span></MkInput> + <MkInput v-model:value="value.inc" type="number"><span>{{ $t('_pages.blocks._counter.inc') }}</span></MkInput> </section> -</x-container> +</XContainer> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons'; import XContainer from '../page-editor.container.vue'; -import MkInput from '../../../components/ui/input.vue'; +import MkInput from '@/components/ui/input.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XContainer, MkInput }, @@ -34,7 +35,7 @@ export default Vue.extend({ }, created() { - if (this.value.name == null) Vue.set(this.value, 'name', ''); + if (this.value.name == null) this.value.name = ''; }, }); </script> diff --git a/src/client/pages/page-editor/els/page-editor.el.if.vue b/src/client/pages/page-editor/els/page-editor.el.if.vue index 53cb9e2aee..415c5ff4c0 100644 --- a/src/client/pages/page-editor/els/page-editor.el.if.vue +++ b/src/client/pages/page-editor/els/page-editor.el.if.vue @@ -1,14 +1,14 @@ <template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faQuestion"/> {{ $t('_pages.blocks.if') }}</template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><Fa :icon="faQuestion"/> {{ $t('_pages.blocks.if') }}</template> <template #func> <button @click="add()" class="_button"> - <fa :icon="faPlus"/> + <Fa :icon="faPlus"/> </button> </template> <section class="romcojzs"> - <mk-select v-model="value.var"> + <MkSelect v-model:value="value.var"> <template #label>{{ $t('_pages.blocks._if.variable') }}</template> <option v-for="v in hpml.getVarsByType('boolean')" :value="v.name">{{ v.name }}</option> <optgroup :label="$t('_pages.script.pageVariables')"> @@ -17,21 +17,22 @@ <optgroup :label="$t('_pages.script.enviromentVariables')"> <option v-for="v in hpml.getEnvVarsByType('boolean')" :value="v">{{ v }}</option> </optgroup> - </mk-select> + </MkSelect> - <x-blocks class="children" v-model="value.children" :hpml="hpml"/> + <XBlocks class="children" v-model:value="value.children" :hpml="hpml"/> </section> -</x-container> +</XContainer> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { v4 as uuid } from 'uuid'; import { faPlus, faQuestion } from '@fortawesome/free-solid-svg-icons'; import XContainer from '../page-editor.container.vue'; -import MkSelect from '../../../components/ui/select.vue'; +import MkSelect from '@/components/ui/select.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XContainer, MkSelect }, @@ -58,13 +59,13 @@ export default Vue.extend({ }, created() { - if (this.value.children == null) Vue.set(this.value, 'children', []); - if (this.value.var === undefined) Vue.set(this.value, 'var', null); + if (this.value.children == null) this.value.children = []; + if (this.value.var === undefined) this.value.var = null; }, methods: { async add() { - const { canceled, result: type } = await this.$root.dialog({ + const { canceled, result: type } = await os.dialog({ type: null, title: this.$t('_pages.chooseBlock'), select: { diff --git a/src/client/pages/page-editor/els/page-editor.el.image.vue b/src/client/pages/page-editor/els/page-editor.el.image.vue index d26d7f603f..f5c2fe816a 100644 --- a/src/client/pages/page-editor/els/page-editor.el.image.vue +++ b/src/client/pages/page-editor/els/page-editor.el.image.vue @@ -1,29 +1,29 @@ <template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faImage"/> {{ $t('_pages.blocks.image') }}</template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><Fa :icon="faImage"/> {{ $t('_pages.blocks.image') }}</template> <template #func> <button @click="choose()"> - <fa :icon="faFolderOpen"/> + <Fa :icon="faFolderOpen"/> </button> </template> <section class="oyyftmcf"> - <mk-file-thumbnail class="preview" v-if="file" :file="file" fit="contain" @click="choose()"/> + <MkDriveFileThumbnail class="preview" v-if="file" :file="file" fit="contain" @click="choose()"/> </section> -</x-container> +</XContainer> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faPencilAlt } from '@fortawesome/free-solid-svg-icons'; import { faImage, faFolderOpen } from '@fortawesome/free-regular-svg-icons'; import XContainer from '../page-editor.container.vue'; -import MkFileThumbnail from '../../../components/drive-file-thumbnail.vue'; -import { selectDriveFile } from '../../../scripts/select-drive-file'; +import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { - XContainer, MkFileThumbnail + XContainer, MkDriveFileThumbnail }, props: { @@ -40,14 +40,14 @@ export default Vue.extend({ }, created() { - if (this.value.fileId === undefined) Vue.set(this.value, 'fileId', null); + if (this.value.fileId === undefined) this.value.fileId = null; }, mounted() { if (this.value.fileId == null) { this.choose(); } else { - this.$root.api('drive/files/show', { + os.api('drive/files/show', { fileId: this.value.fileId }).then(file => { this.file = file; @@ -57,7 +57,7 @@ export default Vue.extend({ methods: { async choose() { - selectDriveFile(this.$root, false).then(file => { + os.selectDriveFile(false).then(file => { this.file = file; this.value.fileId = file.id; }); 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 index 62d2e1bf8a..37b9ac90c3 100644 --- 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 @@ -1,22 +1,23 @@ <template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.numberInput') }}</template> +<XContainer @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> + <MkInput v-model:value="value.name"><template #prefix><Fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._numberInput.name') }}</span></MkInput> + <MkInput v-model:value="value.text"><span>{{ $t('_pages.blocks._numberInput.text') }}</span></MkInput> + <MkInput v-model:value="value.default" type="number"><span>{{ $t('_pages.blocks._numberInput.default') }}</span></MkInput> </section> -</x-container> +</XContainer> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons'; import XContainer from '../page-editor.container.vue'; -import MkInput from '../../../components/ui/input.vue'; +import MkInput from '@/components/ui/input.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XContainer, MkInput }, @@ -34,7 +35,7 @@ export default Vue.extend({ }, created() { - if (this.value.name == null) Vue.set(this.value, 'name', ''); + if (this.value.name == null) this.value.name = ''; }, }); </script> diff --git a/src/client/pages/page-editor/els/page-editor.el.post.vue b/src/client/pages/page-editor/els/page-editor.el.post.vue index 06dea51c1f..19c9c9d7dc 100644 --- a/src/client/pages/page-editor/els/page-editor.el.post.vue +++ b/src/client/pages/page-editor/els/page-editor.el.post.vue @@ -1,24 +1,25 @@ <template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faPaperPlane"/> {{ $t('_pages.blocks.post') }}</template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><Fa :icon="faPaperPlane"/> {{ $t('_pages.blocks.post') }}</template> <section style="padding: 16px;"> - <mk-textarea v-model="value.text">{{ $t('_pages.blocks._post.text') }}</mk-textarea> - <mk-switch v-model="value.attachCanvasImage"><span>{{ $t('_pages.blocks._post.attachCanvasImage') }}</span></mk-switch> - <mk-input v-if="value.attachCanvasImage" v-model="value.canvasId"><span>{{ $t('_pages.blocks._post.canvasId') }}</span></mk-input> + <MkTextarea v-model:value="value.text">{{ $t('_pages.blocks._post.text') }}</MkTextarea> + <MkSwitch v-model:value="value.attachCanvasImage"><span>{{ $t('_pages.blocks._post.attachCanvasImage') }}</span></MkSwitch> + <MkInput v-if="value.attachCanvasImage" v-model:value="value.canvasId"><span>{{ $t('_pages.blocks._post.canvasId') }}</span></MkInput> </section> -</x-container> +</XContainer> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faPaperPlane } from '@fortawesome/free-regular-svg-icons'; import XContainer from '../page-editor.container.vue'; -import MkTextarea from '../../../components/ui/textarea.vue'; -import MkInput from '../../../components/ui/input.vue'; -import MkSwitch from '../../../components/ui/switch.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XContainer, MkTextarea, MkInput, MkSwitch }, @@ -36,9 +37,9 @@ export default Vue.extend({ }, created() { - if (this.value.text == null) Vue.set(this.value, 'text', ''); - if (this.value.attachCanvasImage == null) Vue.set(this.value, 'attachCanvasImage', false); - if (this.value.canvasId == null) Vue.set(this.value, 'canvasId', ''); + if (this.value.text == null) this.value.text = ''; + if (this.value.attachCanvasImage == null) this.value.attachCanvasImage = false; + if (this.value.canvasId == null) this.value.canvasId = ''; }, }); </script> diff --git a/src/client/pages/page-editor/els/page-editor.el.radio-button.vue b/src/client/pages/page-editor/els/page-editor.el.radio-button.vue index 34a9366d62..e30a7d363e 100644 --- a/src/client/pages/page-editor/els/page-editor.el.radio-button.vue +++ b/src/client/pages/page-editor/els/page-editor.el.radio-button.vue @@ -1,24 +1,25 @@ <template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.radioButton') }}</template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><Fa :icon="faBolt"/> {{ $t('_pages.blocks.radioButton') }}</template> <section style="padding: 0 16px 16px 16px;"> - <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> + <MkInput v-model:value="value.name"><template #prefix><Fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._radioButton.name') }}</span></MkInput> + <MkInput v-model:value="value.title"><span>{{ $t('_pages.blocks._radioButton.title') }}</span></MkInput> + <MkTextarea v-model:value="values"><span>{{ $t('_pages.blocks._radioButton.values') }}</span></MkTextarea> + <MkInput v-model:value="value.default"><span>{{ $t('_pages.blocks._radioButton.default') }}</span></MkInput> </section> -</x-container> +</XContainer> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons'; import XContainer from '../page-editor.container.vue'; -import MkTextarea from '../../../components/ui/textarea.vue'; -import MkInput from '../../../components/ui/input.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import MkInput from '@/components/ui/input.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XContainer, MkTextarea, MkInput }, @@ -34,14 +35,17 @@ export default Vue.extend({ }; }, watch: { - values() { - Vue.set(this.value, 'values', this.values.split('\n')); + values: { + handler() { + this.value.values = this.values.split('\n'); + }, + deep: true } }, created() { - if (this.value.name == null) Vue.set(this.value, 'name', ''); - if (this.value.title == null) Vue.set(this.value, 'title', ''); - if (this.value.values == null) Vue.set(this.value, 'values', []); + if (this.value.name == null) this.value.name = ''; + if (this.value.title == null) this.value.title = ''; + if (this.value.values == null) this.value.values = []; this.values = this.value.values.join('\n'); }, }); diff --git a/src/client/pages/page-editor/els/page-editor.el.section.vue b/src/client/pages/page-editor/els/page-editor.el.section.vue index e89a8b840c..df423f0020 100644 --- a/src/client/pages/page-editor/els/page-editor.el.section.vue +++ b/src/client/pages/page-editor/els/page-editor.el.section.vue @@ -1,29 +1,30 @@ <template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faStickyNote"/> {{ value.title }}</template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><Fa :icon="faStickyNote"/> {{ value.title }}</template> <template #func> <button @click="rename()" class="_button"> - <fa :icon="faPencilAlt"/> + <Fa :icon="faPencilAlt"/> </button> <button @click="add()" class="_button"> - <fa :icon="faPlus"/> + <Fa :icon="faPlus"/> </button> </template> <section class="ilrvjyvi"> - <x-blocks class="children" v-model="value.children" :hpml="hpml"/> + <XBlocks class="children" v-model:value="value.children" :hpml="hpml"/> </section> -</x-container> +</XContainer> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } 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 XContainer from '../page-editor.container.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XContainer }, @@ -50,8 +51,8 @@ export default Vue.extend({ }, created() { - if (this.value.title == null) Vue.set(this.value, 'title', null); - if (this.value.children == null) Vue.set(this.value, 'children', []); + if (this.value.title == null) this.value.title = null; + if (this.value.children == null) this.value.children = []; }, mounted() { @@ -62,7 +63,7 @@ export default Vue.extend({ methods: { async rename() { - const { canceled, result: title } = await this.$root.dialog({ + const { canceled, result: title } = await os.dialog({ title: 'Enter title', input: { type: 'text', @@ -75,7 +76,7 @@ export default Vue.extend({ }, async add() { - const { canceled, result: type } = await this.$root.dialog({ + const { canceled, result: type } = await os.dialog({ type: null, title: this.$t('_pages.chooseBlock'), select: { 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 index 5055da4f6f..94ebda40b1 100644 --- a/src/client/pages/page-editor/els/page-editor.el.switch.vue +++ b/src/client/pages/page-editor/els/page-editor.el.switch.vue @@ -1,23 +1,24 @@ <template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.switch') }}</template> +<XContainer @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> + <MkInput v-model:value="value.name"><template #prefix><Fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._switch.name') }}</span></MkInput> + <MkInput v-model:value="value.text"><span>{{ $t('_pages.blocks._switch.text') }}</span></MkInput> + <MkSwitch v-model:value="value.default"><span>{{ $t('_pages.blocks._switch.default') }}</span></MkSwitch> </section> -</x-container> +</XContainer> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons'; import XContainer from '../page-editor.container.vue'; -import MkSwitch from '../../../components/ui/switch.vue'; -import MkInput from '../../../components/ui/input.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import MkInput from '@/components/ui/input.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XContainer, MkSwitch, MkInput }, @@ -35,7 +36,7 @@ export default Vue.extend({ }, created() { - if (this.value.name == null) Vue.set(this.value, 'name', ''); + if (this.value.name == null) this.value.name = ''; }, }); </script> 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 index bd5fb37617..90039a3c9a 100644 --- 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 @@ -1,22 +1,23 @@ <template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.textInput') }}</template> +<XContainer @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> + <MkInput v-model:value="value.name"><template #prefix><Fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._textInput.name') }}</span></MkInput> + <MkInput v-model:value="value.text"><span>{{ $t('_pages.blocks._textInput.text') }}</span></MkInput> + <MkInput v-model:value="value.default" type="text"><span>{{ $t('_pages.blocks._textInput.default') }}</span></MkInput> </section> -</x-container> +</XContainer> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons'; import XContainer from '../page-editor.container.vue'; -import MkInput from '../../../components/ui/input.vue'; +import MkInput from '@/components/ui/input.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XContainer, MkInput }, @@ -34,7 +35,7 @@ export default Vue.extend({ }, created() { - if (this.value.name == null) Vue.set(this.value, 'name', ''); + if (this.value.name == null) this.value.name = ''; }, }); </script> diff --git a/src/client/pages/page-editor/els/page-editor.el.text.vue b/src/client/pages/page-editor/els/page-editor.el.text.vue index a50b1113bd..fcce180f38 100644 --- a/src/client/pages/page-editor/els/page-editor.el.text.vue +++ b/src/client/pages/page-editor/els/page-editor.el.text.vue @@ -1,19 +1,20 @@ <template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faAlignLeft"/> {{ $t('_pages.blocks.text') }}</template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><Fa :icon="faAlignLeft"/> {{ $t('_pages.blocks.text') }}</template> <section class="vckmsadr"> <textarea v-model="value.text"></textarea> </section> -</x-container> +</XContainer> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faAlignLeft } from '@fortawesome/free-solid-svg-icons'; import XContainer from '../page-editor.container.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XContainer }, @@ -31,7 +32,7 @@ export default Vue.extend({ }, created() { - if (this.value.text == null) Vue.set(this.value, 'text', ''); + if (this.value.text == null) this.value.text = ''; }, }); </script> 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 index 33c49c705b..ea00860fe1 100644 --- 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 @@ -1,23 +1,24 @@ <template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.textareaInput') }}</template> +<XContainer @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> + <MkInput v-model:value="value.name"><template #prefix><Fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._textareaInput.name') }}</span></MkInput> + <MkInput v-model:value="value.text"><span>{{ $t('_pages.blocks._textareaInput.text') }}</span></MkInput> + <MkTextarea v-model:value="value.default"><span>{{ $t('_pages.blocks._textareaInput.default') }}</span></MkTextarea> </section> -</x-container> +</XContainer> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons'; import XContainer from '../page-editor.container.vue'; -import MkTextarea from '../../../components/ui/textarea.vue'; -import MkInput from '../../../components/ui/input.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import MkInput from '@/components/ui/input.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XContainer, MkTextarea, MkInput }, @@ -35,7 +36,7 @@ export default Vue.extend({ }, created() { - if (this.value.name == null) Vue.set(this.value, 'name', ''); + if (this.value.name == null) this.value.name = ''; }, }); </script> diff --git a/src/client/pages/page-editor/els/page-editor.el.textarea.vue b/src/client/pages/page-editor/els/page-editor.el.textarea.vue index e2e8848ccf..38c901d79b 100644 --- a/src/client/pages/page-editor/els/page-editor.el.textarea.vue +++ b/src/client/pages/page-editor/els/page-editor.el.textarea.vue @@ -1,19 +1,20 @@ <template> -<x-container @remove="() => $emit('remove')" :draggable="true"> - <template #header><fa :icon="faAlignLeft"/> {{ $t('_pages.blocks.textarea') }}</template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><Fa :icon="faAlignLeft"/> {{ $t('_pages.blocks.textarea') }}</template> <section class="ihymsbbe"> <textarea v-model="value.text"></textarea> </section> -</x-container> +</XContainer> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faAlignLeft } from '@fortawesome/free-solid-svg-icons'; import XContainer from '../page-editor.container.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XContainer }, @@ -31,7 +32,7 @@ export default Vue.extend({ }, created() { - if (this.value.text == null) Vue.set(this.value, 'text', ''); + if (this.value.text == null) this.value.text = ''; }, }); </script> diff --git a/src/client/pages/page-editor/page-editor.blocks.vue b/src/client/pages/page-editor/page-editor.blocks.vue index 6e9408e0b7..48e7fde404 100644 --- a/src/client/pages/page-editor/page-editor.blocks.vue +++ b/src/client/pages/page-editor/page-editor.blocks.vue @@ -1,12 +1,11 @@ <template> -<x-draggable tag="div" :list="blocks" handle=".drag-handle" :group="{ name: 'blocks' }" animation="150" swap-threshold="0.5"> - <component v-for="block in blocks" :is="'x-' + block.type" :value="block" @input="updateItem" @remove="() => removeItem(block)" :key="block.id" :hpml="hpml"/> -</x-draggable> +<XDraggable tag="div" :list="blocks" handle=".drag-handle" :group="{ name: 'blocks' }" animation="150" swap-threshold="0.5"> + <component v-for="block in blocks" :is="'x-' + block.type" :value="block" @update:value="updateItem" @remove="() => removeItem(block)" :key="block.id" :hpml="hpml"/> +</XDraggable> </template> <script lang="ts"> -import Vue from 'vue'; -import * as XDraggable from 'vuedraggable'; +import { defineComponent, defineAsyncComponent } from 'vue'; import XSection from './els/page-editor.el.section.vue'; import XText from './els/page-editor.el.text.vue'; import XTextarea from './els/page-editor.el.textarea.vue'; @@ -21,10 +20,12 @@ import XPost from './els/page-editor.el.post.vue'; import XCounter from './els/page-editor.el.counter.vue'; import XRadioButton from './els/page-editor.el.radio-button.vue'; import XCanvas from './els/page-editor.el.canvas.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { - XDraggable, XSection, XText, XImage, XButton, XTextarea, XTextInput, XTextareaInput, XNumberInput, XSwitch, XIf, XPost, XCounter, XRadioButton, XCanvas + XDraggable: defineAsyncComponent(() => import('vue-draggable-next').then(x => x.VueDraggableNext)), + XSection, XText, XImage, XButton, XTextarea, XTextInput, XTextareaInput, XNumberInput, XSwitch, XIf, XPost, XCounter, XRadioButton, XCanvas }, props: { @@ -51,7 +52,7 @@ export default Vue.extend({ v, ...this.blocks.slice(i + 1) ]; - this.$emit('input', newValue); + this.$emit('update:value', newValue); }, removeItem(el) { @@ -60,7 +61,7 @@ export default Vue.extend({ ...this.blocks.slice(0, i), ...this.blocks.slice(i + 1) ]; - this.$emit('input', newValue); + this.$emit('update:value', newValue); }, } }); diff --git a/src/client/pages/page-editor/page-editor.container.vue b/src/client/pages/page-editor/page-editor.container.vue index be243b8990..6f7443599d 100644 --- a/src/client/pages/page-editor/page-editor.container.vue +++ b/src/client/pages/page-editor/page-editor.container.vue @@ -5,14 +5,14 @@ <div class="buttons"> <slot name="func"></slot> <button v-if="removable" @click="remove()" class="_button"> - <fa :icon="faTrashAlt"/> + <Fa :icon="faTrashAlt"/> </button> <button v-if="draggable" class="drag-handle _button"> - <fa :icon="faBars"/> + <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> + <template v-if="showBody"><Fa :icon="faAngleUp"/></template> + <template v-else><Fa :icon="faAngleDown"/></template> </button> </div> </header> @@ -25,11 +25,12 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faBars, faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons'; import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { expanded: { type: Boolean, @@ -147,7 +148,7 @@ export default Vue.extend({ } > .body { - ::v-deep .juejbjww, ::v-deep .eiipwacr { + ::v-deep(.juejbjww), ::v-deep(.eiipwacr) { &:not(.inline):first-child { margin-top: 28px; } diff --git a/src/client/pages/page-editor/page-editor.script-block.vue b/src/client/pages/page-editor/page-editor.script-block.vue index f3270f02e3..edef095168 100644 --- a/src/client/pages/page-editor/page-editor.script-block.vue +++ b/src/client/pages/page-editor/page-editor.script-block.vue @@ -1,9 +1,9 @@ <template> -<x-container :removable="removable" @remove="() => $emit('remove')" :error="error" :warn="warn" :draggable="draggable"> - <template #header><fa v-if="icon" :icon="icon"/> <template v-if="title">{{ title }} <span class="turmquns" v-if="typeText">({{ typeText }})</span></template><template v-else-if="typeText">{{ typeText }}</template></template> +<XContainer :removable="removable" @remove="() => $emit('remove')" :error="error" :warn="warn" :draggable="draggable"> + <template #header><Fa v-if="icon" :icon="icon"/> <template v-if="title">{{ title }} <span class="turmquns" v-if="typeText">({{ typeText }})</span></template><template v-else-if="typeText">{{ typeText }}</template></template> <template #func> <button @click="changeType()" class="_button"> - <fa :icon="faPencilAlt"/> + <Fa :icon="faPencilAlt"/> </button> </template> @@ -40,30 +40,31 @@ <input v-model="value.value"/> </section> <section v-else-if="value.type === 'fn'" class="" style="padding:0 16px 16px 16px;"> - <mk-textarea v-model="slots"> + <MkTextarea v-model:value="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" :hpml="hpml" :fn-slots="value.value.slots" :name="name"/> + </MkTextarea> + <XV v-if="value.value.expression" v-model:value="value.value.expression" :title="$t(`_pages.script.blocks._fn.arg1`)" :get-expected-type="() => null" :hpml="hpml" :fn-slots="value.value.slots" :name="name"/> </section> <section v-else-if="value.type.startsWith('fn:')" class="" style="padding:16px;"> - <x-v v-for="(x, i) in value.args" v-model="value.args[i]" :title="hpml.getVarByName(value.type.split(':')[1]).value.slots[i].name" :get-expected-type="() => null" :hpml="hpml" :name="name" :key="i"/> + <XV v-for="(x, i) in value.args" v-model:value="value.args[i]" :title="hpml.getVarByName(value.type.split(':')[1]).value.slots[i].name" :get-expected-type="() => null" :hpml="hpml" :name="name" :key="i"/> </section> <section v-else class="" style="padding:16px;"> - <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)" :hpml="hpml" :name="name" :fn-slots="fnSlots" :key="i"/> + <XV v-for="(x, i) in value.args" v-model:value="value.args[i]" :title="$t(`_pages.script.blocks._${value.type}.arg${i + 1}`)" :get-expected-type="() => _getExpectedType(i)" :hpml="hpml" :name="name" :fn-slots="fnSlots" :key="i"/> </section> -</x-container> +</XContainer> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faPencilAlt, faPlug } from '@fortawesome/free-solid-svg-icons'; import { v4 as uuid } from 'uuid'; import XContainer from './page-editor.container.vue'; -import MkTextarea from '../../components/ui/textarea.vue'; -import { isLiteralBlock, funcDefs, blockDefs } from '../../scripts/hpml/index'; +import MkTextarea from '@/components/ui/textarea.vue'; +import { isLiteralBlock, funcDefs, blockDefs } from '@/scripts/hpml/index'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XContainer, MkTextarea }, @@ -123,11 +124,14 @@ export default Vue.extend({ }, watch: { - slots() { - this.value.value.slots = this.slots.split('\n').map(x => ({ - name: x, - type: null - })); + slots: { + handler() { + this.value.value.slots = this.slots.split('\n').map(x => ({ + name: x, + type: null + })); + }, + deep: true } }, @@ -136,18 +140,19 @@ export default Vue.extend({ }, created() { - if (this.value.value == null) Vue.set(this.value, 'value', null); + if (this.value.value == null) this.value.value = null; if (this.value.value && this.value.value.slots) this.slots = this.value.value.slots.map(x => x.name).join('\n'); - this.$watch('value.type', (t) => { + this.$watch(() => this.value.type, (t) => { this.warn = null; if (this.value.type === 'fn') { const id = uuid(); - this.value.value = {}; - Vue.set(this.value.value, 'slots', []); - Vue.set(this.value.value, 'expression', { id, type: null }); + this.value.value = { + slots: [], + expression: { id, type: null } + }; return; } @@ -160,7 +165,7 @@ export default Vue.extend({ const id = uuid(); empties.push({ id, type: null }); } - Vue.set(this.value, 'args', empties); + this.value.args = empties; return; } @@ -171,7 +176,7 @@ export default Vue.extend({ const id = uuid(); empties.push({ id, type: null }); } - Vue.set(this.value, 'args', empties); + this.value.args = empties; for (let i = 0; i < funcDefs[this.value.type].in.length; i++) { const inType = funcDefs[this.value.type].in[i]; @@ -182,7 +187,7 @@ export default Vue.extend({ } }); - this.$watch('value.args', (args) => { + this.$watch(() => this.value.args, (args) => { if (args == null) { this.warn = null; return; @@ -199,7 +204,7 @@ export default Vue.extend({ deep: true }); - this.$watch('hpml.variables', () => { + this.$watch(() => this.hpml.variables, () => { if (this.type != null && this.value) { this.error = this.hpml.typeCheck(this.value); } @@ -210,7 +215,7 @@ export default Vue.extend({ methods: { async changeType() { - const { canceled, result: type } = await this.$root.dialog({ + const { canceled, result: type } = await os.dialog({ type: null, title: this.$t('_pages.selectType'), select: { diff --git a/src/client/pages/page-editor/page-editor.vue b/src/client/pages/page-editor/page-editor.vue index 8f09ccec33..363f46c34b 100644 --- a/src/client/pages/page-editor/page-editor.vue +++ b/src/client/pages/page-editor/page-editor.vue @@ -1,117 +1,118 @@ <template> -<div> - <div class="gwbmwxkm _panel"> - <header> - <div class="title"><fa :icon="faStickyNote"/> {{ readonly ? $t('_pages.readPage') : pageId ? $t('_pages.editPage') : $t('_pages.newPage') }}</div> - <div class="buttons"> - <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> +<div class="_section"> + <div class="_content"> + <div class="gwbmwxkm _panel _vMargin"> + <header> + <div class="title"><Fa :icon="faStickyNote"/> {{ readonly ? $t('_pages.readPage') : pageId ? $t('_pages.editPage') : $t('_pages.newPage') }}</div> + <div class="buttons"> + <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('_pages.viewPage') }}</router-link> + <section> + <router-link class="view" v-if="pageId" :to="`/@${ author.username }/pages/${ currentName }`"><Fa :icon="faExternalLinkSquareAlt"/> {{ $t('_pages.viewPage') }}</router-link> - <mk-input v-model="title"> - <span>{{ $t('_pages.title') }}</span> - </mk-input> + <MkInput v-model:value="title"> + <span>{{ $t('_pages.title') }}</span> + </MkInput> - <template v-if="showOptions"> - <mk-input v-model="summary"> - <span>{{ $t('_pages.summary') }}</span> - </mk-input> + <template v-if="showOptions"> + <MkInput v-model:value="summary"> + <span>{{ $t('_pages.summary') }}</span> + </MkInput> - <mk-input v-model="name"> - <template #prefix>{{ url }}/@{{ author.username }}/pages/</template> - <span>{{ $t('_pages.url') }}</span> - </mk-input> + <MkInput v-model:value="name"> + <template #prefix>{{ url }}/@{{ author.username }}/pages/</template> + <span>{{ $t('_pages.url') }}</span> + </MkInput> - <mk-switch v-model="alignCenter">{{ $t('_pages.alignCenter') }}</mk-switch> + <MkSwitch v-model:value="alignCenter">{{ $t('_pages.alignCenter') }}</MkSwitch> - <mk-select v-model="font"> - <template #label>{{ $t('_pages.font') }}</template> - <option value="serif">{{ $t('_pages.fontSerif') }}</option> - <option value="sans-serif">{{ $t('_pages.fontSansSerif') }}</option> - </mk-select> + <MkSelect v-model:value="font"> + <template #label>{{ $t('_pages.font') }}</template> + <option value="serif">{{ $t('_pages.fontSerif') }}</option> + <option value="sans-serif">{{ $t('_pages.fontSansSerif') }}</option> + </MkSelect> - <mk-switch v-model="hideTitleWhenPinned">{{ $t('_pages.hideTitleWhenPinned') }}</mk-switch> + <MkSwitch v-model:value="hideTitleWhenPinned">{{ $t('_pages.hideTitleWhenPinned') }}</MkSwitch> - <div class="eyeCatch"> - <mk-button v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage()"><fa :icon="faPlus"/> {{ $t('_pages.eyeCatchingImageSet') }}</mk-button> - <div v-else-if="eyeCatchingImage"> - <img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name"/> - <mk-button @click="removeEyeCatchingImage()" v-if="!readonly"><fa :icon="faTrashAlt"/> {{ $t('_pages.eyeCatchingImageRemove') }}</mk-button> + <div class="eyeCatch"> + <MkButton v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage()"><Fa :icon="faPlus"/> {{ $t('_pages.eyeCatchingImageSet') }}</MkButton> + <div v-else-if="eyeCatchingImage"> + <img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name"/> + <MkButton @click="removeEyeCatchingImage()" v-if="!readonly"><Fa :icon="faTrashAlt"/> {{ $t('_pages.eyeCatchingImageRemove') }}</MkButton> + </div> </div> - </div> - </template> + </template> - <x-blocks class="content" v-model="content" :hpml="hpml"/> + <XBlocks class="content" v-model:value="content" :hpml="hpml"/> - <mk-button @click="add()" v-if="!readonly"><fa :icon="faPlus"/></mk-button> - </section> - </div> + <MkButton @click="add()" v-if="!readonly"><Fa :icon="faPlus"/></MkButton> + </section> + </div> - <mk-container :body-togglable="true"> - <template #header><fa :icon="faMagic"/> {{ $t('_pages.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"> - <x-variable v-for="variable in variables" - :value="variable" - :removable="true" - @input="v => updateVariable(v)" - @remove="() => removeVariable(variable)" - :key="variable.name" - :hpml="hpml" - :name="variable.name" - :title="variable.name" - :draggable="true" - /> - </x-draggable> + <MkContainer :body-togglable="true" class="_vMargin"> + <template #header><Fa :icon="faMagic"/> {{ $t('_pages.variables') }}</template> + <div class="qmuvgica"> + <XDraggable tag="div" class="variables" v-show="variables.length > 0" :list="variables" handle=".drag-handle" :group="{ name: 'variables' }" animation="150" swap-threshold="0.5"> + <XVariable v-for="variable in variables" + :value="variable" + :removable="true" + @update:value="v => updateVariable(v)" + @remove="() => removeVariable(variable)" + :key="variable.name" + :hpml="hpml" + :name="variable.name" + :title="variable.name" + :draggable="true" + /> + </XDraggable> - <mk-button @click="addVariable()" class="add" v-if="!readonly"><fa :icon="faPlus"/></mk-button> - </div> - </mk-container> + <MkButton @click="addVariable()" class="add" v-if="!readonly"><Fa :icon="faPlus"/></MkButton> + </div> + </MkContainer> - <mk-container :body-togglable="true" :expanded="true"> - <template #header><fa :icon="faCode"/> {{ $t('script') }}</template> - <div> - <prism-editor class="_code" v-model="script" :highlight="highlighter" :line-numbers="false"/> - </div> - </mk-container> + <MkContainer :body-togglable="true" :expanded="true" class="_vMargin"> + <template #header><Fa :icon="faCode"/> {{ $t('script') }}</template> + <div> + <MkTextarea class="_code" v-model:value="script"/> + </div> + </MkContainer> + </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; -import * as XDraggable from 'vuedraggable'; +import { defineComponent, defineAsyncComponent } from 'vue'; import 'prismjs'; import { highlight, languages } from 'prismjs/components/prism-core'; import 'prismjs/components/prism-clike'; import 'prismjs/components/prism-javascript'; import 'prismjs/themes/prism-okaidia.css'; -import { PrismEditor } from 'vue-prism-editor'; import 'vue-prism-editor/dist/prismeditor.min.css'; import { faICursor, faPlus, faMagic, faCog, faCode, faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons'; import { faSave, faStickyNote, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; import { v4 as uuid } from 'uuid'; import XVariable from './page-editor.script-block.vue'; import XBlocks from './page-editor.blocks.vue'; -import MkTextarea from '../../components/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/hpml/index'; -import { HpmlTypeChecker } from '../../scripts/hpml/type-checker'; -import { url } from '../../config'; -import { collectPageVars } from '../../scripts/collect-page-vars'; -import { selectDriveFile } from '../../scripts/select-drive-file'; +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/hpml/index'; +import { HpmlTypeChecker } from '@/scripts/hpml/type-checker'; +import { url } from '@/config'; +import { collectPageVars } from '@/scripts/collect-page-vars'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { - XDraggable, XVariable, XBlocks, MkTextarea, MkContainer, MkButton, MkSelect, MkSwitch, MkInput, PrismEditor + XDraggable: defineAsyncComponent(() => import('vue-draggable-next').then(x => x.VueDraggableNext)), + XVariable, XBlocks, MkTextarea, MkContainer, MkButton, MkSelect, MkSwitch, MkInput, }, props: { @@ -159,7 +160,7 @@ export default Vue.extend({ if (this.eyeCatchingImageId == null) { this.eyeCatchingImage = null; } else { - this.eyeCatchingImage = await this.$root.api('drive/files/show', { + this.eyeCatchingImage = await os.api('drive/files/show', { fileId: this.eyeCatchingImageId, }); } @@ -178,11 +179,11 @@ export default Vue.extend({ }, { deep: true }); if (this.initPageId) { - this.page = await this.$root.api('pages/show', { + this.page = await os.api('pages/show', { pageId: this.initPageId, }); } else if (this.initPageName && this.initUser) { - this.page = await this.$root.api('pages/show', { + this.page = await os.api('pages/show', { name: this.initPageName, username: this.initUser, }); @@ -239,14 +240,14 @@ export default Vue.extend({ const onError = err => { if (err.id == '3d81ceae-475f-4600-b2a8-2bc116157532') { if (err.info.param == 'name') { - this.$root.dialog({ + os.dialog({ type: 'error', title: this.$t('_pages.invalidNameTitle'), text: this.$t('_pages.invalidNameText') }); } } else if (err.code == 'NAME_ALREADY_EXISTS') { - this.$root.dialog({ + os.dialog({ type: 'error', text: this.$t('_pages.nameAlreadyExists') }); @@ -255,20 +256,20 @@ export default Vue.extend({ if (this.pageId) { options.pageId = this.pageId; - this.$root.api('pages/update', options) + os.api('pages/update', options) .then(page => { this.currentName = this.name.trim(); - this.$root.dialog({ + os.dialog({ type: 'success', text: this.$t('_pages.updated') }); }).catch(onError); } else { - this.$root.api('pages/create', options) + os.api('pages/create', options) .then(page => { this.pageId = page.id; this.currentName = this.name.trim(); - this.$root.dialog({ + os.dialog({ type: 'success', text: this.$t('_pages.created') }); @@ -278,16 +279,16 @@ export default Vue.extend({ }, del() { - this.$root.dialog({ + os.dialog({ type: 'warning', text: this.$t('removeAreYouSure', { x: this.title.trim() }), showCancelButton: true }).then(({ canceled }) => { if (canceled) return; - this.$root.api('pages/delete', { + os.api('pages/delete', { pageId: this.pageId, }).then(() => { - this.$root.dialog({ + os.dialog({ type: 'success', text: this.$t('_pages.deleted') }); @@ -297,7 +298,7 @@ export default Vue.extend({ }, async add() { - const { canceled, result: type } = await this.$root.dialog({ + const { canceled, result: type } = await os.dialog({ type: null, title: this.$t('_pages.chooseBlock'), select: { @@ -312,7 +313,7 @@ export default Vue.extend({ }, async addVariable() { - let { canceled, result: name } = await this.$root.dialog({ + let { canceled, result: name } = await os.dialog({ title: this.$t('_pages.enterVariableName'), input: { type: 'text', @@ -324,7 +325,7 @@ export default Vue.extend({ name = name.trim(); if (this.hpml.isUsedName(name)) { - this.$root.dialog({ + os.dialog({ type: 'error', text: this.$t('_pages.variableNameIsAlreadyUsed') }); @@ -413,7 +414,7 @@ export default Vue.extend({ }, setEyeCatchingImage() { - selectDriveFile(this.$root, false).then(file => { + os.selectDriveFile(false).then(file => { this.eyeCatchingImageId = file.id; }); }, @@ -431,7 +432,7 @@ export default Vue.extend({ <style lang="scss" scoped> .gwbmwxkm { - margin-bottom: var(--margin); + position: relative; > header { > .title { diff --git a/src/client/pages/page.vue b/src/client/pages/page.vue index 093a3e5e2f..eb470fdc19 100644 --- a/src/client/pages/page.vue +++ b/src/client/pages/page.vue @@ -1,41 +1,44 @@ <template> -<div class="xcukqgmh"> - <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> - - <div class="_card" v-if="page" :key="page.id"> - <div class="_title">{{ page.title }}</div> - <div class="banner"> - <img :src="page.eyeCatchingImage.url" v-if="page.eyeCatchingImageId"/> +<div class="xcukqgmh" v-if="page" :key="page.id"> + <div class="_section main"> + <div class="_content"> + <div class="banner"> + <img :src="page.eyeCatchingImage.url" v-if="page.eyeCatchingImageId"/> + </div> + <div> + <XPage :page="page"/> + <small style="display: block; opacity: 0.7; margin-top: 1em;">@{{ page.user.username }}</small> + </div> </div> + </div> + <div class="_section like"> <div class="_content"> - <x-page :page="page"/> + <button class="_button" @click="unlike()" v-if="page.isLiked" :title="$t('_pages.unlike')"><Fa :icon="faHeartS"/></button> + <button class="_button" @click="like()" v-else :title="$t('_pages.like')"><Fa :icon="faHeartR"/></button> + <span class="count" v-if="page.likedCount > 0">{{ page.likedCount }}</span> </div> - <div class="_footer"> - <small>@{{ page.user.username }}</small> + </div> + <div class="_section links"> + <div class="_content"> + <router-link :to="`./${page.name}/view-source`" class="link">{{ $t('_pages.viewSource') }}</router-link> <template v-if="$store.getters.isSignedIn && $store.state.i.id === page.userId"> - <router-link :to="`/my/pages/edit/${page.id}`">{{ $t('_pages.editThisPage') }}</router-link> - <a v-if="$store.state.i.pinnedPageId === page.id" @click="pin(false)">{{ $t('unpin') }}</a> - <a v-else @click="pin(true)">{{ $t('pin') }}</a> + <router-link :to="`/my/pages/edit/${page.id}`" class="link">{{ $t('_pages.editThisPage') }}</router-link> + <button v-if="$store.state.i.pinnedPageId === page.id" @click="pin(false)" class="link _textButton">{{ $t('unpin') }}</button> + <button v-else @click="pin(true)" class="link _textButton">{{ $t('pin') }}</button> </template> - <router-link :to="`./${page.name}/view-source`">{{ $t('_pages.viewSource') }}</router-link> - <div class="like"> - <button class="_button" @click="unlike()" v-if="page.isLiked" :title="$t('_pages.unlike')"><fa :icon="faHeartS"/></button> - <button class="_button" @click="like()" v-else :title="$t('_pages.like')"><fa :icon="faHeartR"/></button> - <span class="count" v-if="page.likedCount > 0">{{ page.likedCount }}</span> - </div> </div> </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { computed, defineComponent } from 'vue'; import { faHeart as faHeartS } from '@fortawesome/free-solid-svg-icons'; import { faHeart as faHeartR } from '@fortawesome/free-regular-svg-icons'; -import XPage from '../components/page/page.vue'; +import XPage from '@/components/page/page.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XPage }, @@ -53,6 +56,12 @@ export default Vue.extend({ data() { return { + INFO: computed(() => this.page ? { + header: [{ + title: computed(() => this.page.title || this.page.name), + avatar: this.page.user, + }], + } : null), page: null, faHeartS, faHeartR }; @@ -76,7 +85,7 @@ export default Vue.extend({ methods: { fetch() { - this.$root.api('pages/show', { + os.api('pages/show', { name: this.pageName, username: this.username, }).then(page => { @@ -85,7 +94,7 @@ export default Vue.extend({ }, like() { - this.$root.api('pages/like', { + os.api('pages/like', { pageId: this.page.id, }).then(() => { this.page.isLiked = true; @@ -94,7 +103,7 @@ export default Vue.extend({ }, unlike() { - this.$root.api('pages/unlike', { + os.api('pages/unlike', { pageId: this.page.id, }).then(() => { this.page.isLiked = false; @@ -103,13 +112,8 @@ export default Vue.extend({ }, pin(pin) { - this.$root.api('i/update', { + os.apiWithDialog('i/update', { pinnedPageId: pin ? this.page.id : null, - }).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); }); } } @@ -118,19 +122,23 @@ export default Vue.extend({ <style lang="scss" scoped> .xcukqgmh { - > ._card { - > .banner { - > img { - display: block; - width: 100%; - height: 120px; - object-fit: cover; + > .main { + > ._content { + > .banner { + > img { + display: block; + width: 100%; + height: 120px; + object-fit: cover; + } } } + } - > ._footer { - > * { - margin: 0 0.5em; + > .links { + > ._content { + > .link { + margin-right: 0.75em; } } } diff --git a/src/client/pages/pages.vue b/src/client/pages/pages.vue index 9f9c68ee28..e8f364bb8d 100644 --- a/src/client/pages/pages.vue +++ b/src/client/pages/pages.vue @@ -1,40 +1,47 @@ <template> <div> - <portal to="icon"><fa :icon="faStickyNote"/></portal> - <portal to="title">{{ $t('pages') }}</portal> - - <mk-tab v-model="tab" :items="[{ label: $t('_pages.my'), value: 'my', icon: faEdit }, { label: $t('_pages.liked'), value: 'liked', icon: faHeart }]"/> + <MkTab v-model:value="tab" :items="[{ label: $t('_pages.my'), value: 'my', icon: faEdit }, { label: $t('_pages.liked'), value: 'liked', icon: faHeart }]"/> <div class="rknalgpo my" v-if="tab === '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> + <MkButton class="new" @click="create()"><Fa :icon="faPlus"/></MkButton> + <MkPagination :pagination="myPagesPagination" #default="{items}"> + <MkPagePreview v-for="page in items" class="ckltabjg" :page="page" :key="page.id"/> + </MkPagination> </div> <div class="rknalgpo" v-if="tab === 'liked'"> - <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> + <MkPagination :pagination="likedPagesPagination" #default="{items}"> + <MkPagePreview v-for="like in items" class="ckltabjg" :page="like.page" :key="like.page.id"/> + </MkPagination> </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faPlus, faEdit } from '@fortawesome/free-solid-svg-icons'; import { faStickyNote, faHeart } from '@fortawesome/free-regular-svg-icons'; -import MkPagePreview from '../components/page-preview.vue'; -import MkPagination from '../components/ui/pagination.vue'; -import MkButton from '../components/ui/button.vue'; -import MkTab from '../components/tab.vue'; +import MkPagePreview from '@/components/page-preview.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkTab from '@/components/tab.vue'; -export default Vue.extend({ +export default defineComponent({ components: { MkPagePreview, MkPagination, MkButton, MkTab }, data() { return { + INFO: { + header: [{ + title: this.$t('pages'), + icon: faStickyNote + }], + action: { + icon: faPlus, + handler: this.create + } + }, tab: 'my', myPagesPagination: { endpoint: 'i/pages', diff --git a/src/client/pages/preferences/index.vue b/src/client/pages/preferences/index.vue deleted file mode 100644 index 4e171bfcbe..0000000000 --- a/src/client/pages/preferences/index.vue +++ /dev/null @@ -1,360 +0,0 @@ -<template> -<div> - <portal to="icon"><fa :icon="faCog"/></portal> - <portal to="title">{{ $t('clinetSettings') }}</portal> - - <router-link v-if="$store.getters.isSignedIn" class="_panel _buttonPrimary" to="/my/settings" style="margin-bottom: var(--margin);">{{ $t('accountSettings') }}</router-link> - - <x-theme class="_vMargin"/> - - <x-sidebar class="_vMargin"/> - - <x-plugins class="_vMargin"/> - - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faMusic"/> {{ $t('sounds') }}</div> - <div class="_content"> - <mk-range v-model="sfxVolume" :min="0" :max="1" :step="0.1"> - <fa slot="icon" :icon="volumeIcon"/> - <span slot="title">{{ $t('volume') }}</span> - </mk-range> - </div> - <div class="_content"> - <mk-select v-model="sfxNote"> - <template #label>{{ $t('_sfx.note') }}</template> - <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> - <template #text><button class="_textButton" @click="listen(sfxNote)" v-if="sfxNote"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template> - </mk-select> - <mk-select v-model="sfxNoteMy"> - <template #label>{{ $t('_sfx.noteMy') }}</template> - <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> - <template #text><button class="_textButton" @click="listen(sfxNoteMy)" v-if="sfxNoteMy"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template> - </mk-select> - <mk-select v-model="sfxNotification"> - <template #label>{{ $t('_sfx.notification') }}</template> - <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> - <template #text><button class="_textButton" @click="listen(sfxNotification)" v-if="sfxNotification"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template> - </mk-select> - <mk-select v-model="sfxChat"> - <template #label>{{ $t('_sfx.chat') }}</template> - <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> - <template #text><button class="_textButton" @click="listen(sfxChat)" v-if="sfxChat"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template> - </mk-select> - <mk-select v-model="sfxChatBg"> - <template #label>{{ $t('_sfx.chatBg') }}</template> - <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> - <template #text><button class="_textButton" @click="listen(sfxChatBg)" v-if="sfxChatBg"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template> - </mk-select> - <mk-select v-model="sfxAntenna"> - <template #label>{{ $t('_sfx.antenna') }}</template> - <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> - <template #text><button class="_textButton" @click="listen(sfxAntenna)" v-if="sfxAntenna"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template> - </mk-select> - <mk-select v-model="sfxChannel"> - <template #label>{{ $t('_sfx.channel') }}</template> - <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> - <template #text><button class="_textButton" @click="listen(sfxChannel)" v-if="sfxChannel"><fa :icon="faPlay"/> {{ $t('listen') }}</button></template> - </mk-select> - </div> - </section> - - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faColumns"/> {{ $t('deck') }}</div> - <div class="_content"> - <mk-switch v-model="deckAlwaysShowMainColumn"> - {{ $t('_deck.alwaysShowMainColumn') }} - </mk-switch> - </div> - <div class="_content"> - <div>{{ $t('_deck.columnAlign') }}</div> - <mk-radio v-model="deckColumnAlign" value="left">{{ $t('left') }}</mk-radio> - <mk-radio v-model="deckColumnAlign" value="center">{{ $t('center') }}</mk-radio> - </div> - </section> - - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faCog"/> {{ $t('appearance') }}</div> - <div class="_content"> - <mk-switch v-model="disableAnimatedMfm">{{ $t('disableAnimatedMfm') }}</mk-switch> - <mk-switch v-model="reduceAnimation">{{ $t('reduceUiAnimation') }}</mk-switch> - <mk-switch v-model="useBlurEffectForModal">{{ $t('useBlurEffectForModal') }}</mk-switch> - <mk-switch v-model="useOsNativeEmojis"> - {{ $t('useOsNativeEmojis') }} - <template #desc><mfm text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></template> - </mk-switch> - </div> - <div class="_content"> - <div>{{ $t('fontSize') }}</div> - <mk-radio v-model="fontSize" value="small"><span style="font-size: 14px;">Aa</span></mk-radio> - <mk-radio v-model="fontSize" :value="null"><span style="font-size: 16px;">Aa</span></mk-radio> - <mk-radio v-model="fontSize" value="large"><span style="font-size: 18px;">Aa</span></mk-radio> - <mk-radio v-model="fontSize" value="veryLarge"><span style="font-size: 20px;">Aa</span></mk-radio> - </div> - </section> - - <section class="_card _vMargin"> - <div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div> - <div class="_content"> - <div>{{ $t('whenServerDisconnected') }}</div> - <mk-radio v-model="serverDisconnectedBehavior" value="reload">{{ $t('_serverDisconnectedBehavior.reload') }}</mk-radio> - <mk-radio v-model="serverDisconnectedBehavior" value="dialog">{{ $t('_serverDisconnectedBehavior.dialog') }}</mk-radio> - <mk-radio v-model="serverDisconnectedBehavior" value="quiet">{{ $t('_serverDisconnectedBehavior.quiet') }}</mk-radio> - </div> - <div class="_content"> - <mk-switch v-model="imageNewTab">{{ $t('openImageInNewTab') }}</mk-switch> - <mk-switch v-model="showFixedPostForm">{{ $t('showFixedPostForm') }}</mk-switch> - <mk-switch v-model="enableInfiniteScroll">{{ $t('enableInfiniteScroll') }}</mk-switch> - <mk-switch v-model="fixedWidgetsPosition">{{ $t('fixedWidgetsPosition') }}</mk-switch> - <mk-switch v-model="disablePagesScript">{{ $t('disablePagesScript') }}</mk-switch> - </div> - <div class="_content"> - <mk-select v-model="lang"> - <template #label>{{ $t('uiLanguage') }}</template> - - <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> - </mk-select> - </div> - </section> - - <mk-button @click="cacheClear()" primary style="margin: var(--margin) auto;">{{ $t('cacheClear') }}</mk-button> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faImage, faCog, faMusic, faPlay, faVolumeUp, faVolumeMute, faColumns } from '@fortawesome/free-solid-svg-icons'; -import MkButton from '../../components/ui/button.vue'; -import MkSwitch from '../../components/ui/switch.vue'; -import MkSelect from '../../components/ui/select.vue'; -import MkRadio from '../../components/ui/radio.vue'; -import MkRange from '../../components/ui/range.vue'; -import XTheme from './theme.vue'; -import XSidebar from './sidebar.vue'; -import XPlugins from './plugins.vue'; -import { langs } from '../../config'; -import { clientDb, set } from '../../db'; - -const sounds = [ - null, - 'syuilo/up', - 'syuilo/down', - 'syuilo/pope1', - 'syuilo/pope2', - 'syuilo/waon', - 'syuilo/popo', - 'syuilo/triple', - 'syuilo/poi1', - 'syuilo/poi2', - 'syuilo/pirori', - 'syuilo/pirori-wet', - 'syuilo/pirori-square-wet', - 'syuilo/square-pico', - 'syuilo/reverved', - 'syuilo/ryukyu', - 'aisha/1', - 'aisha/2', - 'aisha/3', - 'noizenecio/kick_gaba', - 'noizenecio/kick_gaba2', -]; - -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('settings') as string - }; - }, - - components: { - XTheme, - XSidebar, - XPlugins, - MkButton, - MkSwitch, - MkSelect, - MkRadio, - MkRange, - }, - - data() { - return { - langs, - lang: localStorage.getItem('lang'), - fontSize: localStorage.getItem('fontSize'), - sounds, - faImage, faCog, faMusic, faPlay, faVolumeUp, faVolumeMute, faColumns - } - }, - - computed: { - serverDisconnectedBehavior: { - get() { return this.$store.state.device.serverDisconnectedBehavior; }, - set(value) { this.$store.commit('device/set', { key: 'serverDisconnectedBehavior', value }); } - }, - - reduceAnimation: { - get() { return !this.$store.state.device.animation; }, - set(value) { this.$store.commit('device/set', { key: 'animation', value: !value }); } - }, - - useBlurEffectForModal: { - get() { return this.$store.state.device.useBlurEffectForModal; }, - set(value) { this.$store.commit('device/set', { key: 'useBlurEffectForModal', value: value }); } - }, - - disableAnimatedMfm: { - get() { return !this.$store.state.device.animatedMfm; }, - set(value) { this.$store.commit('device/set', { key: 'animatedMfm', value: !value }); } - }, - - useOsNativeEmojis: { - get() { return this.$store.state.device.useOsNativeEmojis; }, - set(value) { this.$store.commit('device/set', { key: 'useOsNativeEmojis', value }); } - }, - - imageNewTab: { - get() { return this.$store.state.device.imageNewTab; }, - set(value) { this.$store.commit('device/set', { key: 'imageNewTab', value }); } - }, - - disablePagesScript: { - get() { return this.$store.state.device.disablePagesScript; }, - set(value) { this.$store.commit('device/set', { key: 'disablePagesScript', value }); } - }, - - showFixedPostForm: { - get() { return this.$store.state.device.showFixedPostForm; }, - set(value) { this.$store.commit('device/set', { key: 'showFixedPostForm', value }); } - }, - - enableInfiniteScroll: { - get() { return this.$store.state.device.enableInfiniteScroll; }, - set(value) { this.$store.commit('device/set', { key: 'enableInfiniteScroll', value }); } - }, - - fixedWidgetsPosition: { - get() { return this.$store.state.device.fixedWidgetsPosition; }, - set(value) { this.$store.commit('device/set', { key: 'fixedWidgetsPosition', value }); } - }, - - deckAlwaysShowMainColumn: { - get() { return this.$store.state.device.deckAlwaysShowMainColumn; }, - set(value) { this.$store.commit('device/set', { key: 'deckAlwaysShowMainColumn', value }); } - }, - - deckColumnAlign: { - get() { return this.$store.state.device.deckColumnAlign; }, - set(value) { this.$store.commit('device/set', { key: 'deckColumnAlign', value }); } - }, - - sfxVolume: { - get() { return this.$store.state.device.sfxVolume; }, - set(value) { this.$store.commit('device/set', { key: 'sfxVolume', value: parseFloat(value, 10) }); } - }, - - sfxNote: { - get() { return this.$store.state.device.sfxNote; }, - set(value) { this.$store.commit('device/set', { key: 'sfxNote', value }); } - }, - - sfxNoteMy: { - get() { return this.$store.state.device.sfxNoteMy; }, - set(value) { this.$store.commit('device/set', { key: 'sfxNoteMy', value }); } - }, - - sfxNotification: { - get() { return this.$store.state.device.sfxNotification; }, - set(value) { this.$store.commit('device/set', { key: 'sfxNotification', value }); } - }, - - sfxChat: { - get() { return this.$store.state.device.sfxChat; }, - set(value) { this.$store.commit('device/set', { key: 'sfxChat', value }); } - }, - - sfxChatBg: { - get() { return this.$store.state.device.sfxChatBg; }, - set(value) { this.$store.commit('device/set', { key: 'sfxChatBg', value }); } - }, - - sfxAntenna: { - get() { return this.$store.state.device.sfxAntenna; }, - set(value) { this.$store.commit('device/set', { key: 'sfxAntenna', value }); } - }, - - sfxChannel: { - get() { return this.$store.state.device.sfxChannel; }, - set(value) { this.$store.commit('device/set', { key: 'sfxChannel', value }); } - }, - - volumeIcon: { - get() { - return this.sfxVolume === 0 ? faVolumeMute : faVolumeUp; - } - } - }, - - watch: { - lang() { - const dialog = this.$root.dialog({ - type: 'waiting', - iconOnly: true - }); - - localStorage.setItem('lang', this.lang); - - return set('_version_', `changeLang-${(new Date()).toJSON()}`, clientDb.i18n) - .then(() => location.reload()) - .catch(() => { - dialog.close(); - this.$root.dialog({ - type: 'error', - iconOnly: true, - autoClose: true - }); - }); - }, - - fontSize() { - if (this.fontSize == null) { - localStorage.removeItem('fontSize'); - } else { - localStorage.setItem('fontSize', this.fontSize); - } - location.reload(); - }, - - fixedWidgetsPosition() { - location.reload() - }, - - enableInfiniteScroll() { - location.reload() - }, - }, - - methods: { - listen(sound) { - const audio = new Audio(`/assets/sounds/${sound}.mp3`); - audio.volume = this.$store.state.device.sfxVolume; - audio.play(); - }, - - 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> diff --git a/src/client/pages/preferences/sidebar.vue b/src/client/pages/preferences/sidebar.vue deleted file mode 100644 index 10aad0f3a0..0000000000 --- a/src/client/pages/preferences/sidebar.vue +++ /dev/null @@ -1,95 +0,0 @@ -<template> -<section class="_card"> - <div class="_title"><fa :icon="faListUl"/> {{ $t('sidebar') }}</div> - <div class="_content"> - <mk-textarea v-model="items" tall> - <span>{{ $t('sidebar') }}</span> - <template #desc><button class="_textButton" @click="addItem">{{ $t('addItem') }}</button></template> - </mk-textarea> - </div> - <div class="_content"> - <div>{{ $t('display') }}</div> - <mk-radio v-model="sidebarDisplay" value="full">{{ $t('_sidebar.full') }}</mk-radio> - <mk-radio v-model="sidebarDisplay" value="icon">{{ $t('_sidebar.icon') }}</mk-radio> - <!-- <mk-radio v-model="sidebarDisplay" value="hide" disabled>{{ $t('_sidebar.hide') }}</mk-radio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 --> - </div> - <div class="_footer"> - <mk-button inline @click="save()" primary><fa :icon="faSave"/> {{ $t('save') }}</mk-button> - <mk-button inline @click="reset()"><fa :icon="faRedo"/> {{ $t('default') }}</mk-button> - </div> -</section> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import { faListUl, faSave, faRedo } from '@fortawesome/free-solid-svg-icons'; -import MkButton from '../../components/ui/button.vue'; -import MkTextarea from '../../components/ui/textarea.vue'; -import MkRadio from '../../components/ui/radio.vue'; -import { defaultDeviceUserSettings } from '../../store'; - -export default Vue.extend({ - components: { - MkButton, - MkTextarea, - MkRadio, - }, - - data() { - return { - menuDef: this.$store.getters.nav({}), - items: '', - faListUl, faSave, faRedo - } - }, - - computed: { - splited(): string[] { - return this.items.trim().split('\n').filter(x => x.trim() !== ''); - }, - - sidebarDisplay: { - get() { return this.$store.state.device.sidebarDisplay; }, - set(value) { this.$store.commit('device/set', { key: 'sidebarDisplay', value }); } - }, - }, - - created() { - this.items = this.$store.state.deviceUser.menu.join('\n'); - }, - - methods: { - async addItem() { - const menu = Object.keys(this.menuDef).filter(k => !this.$store.state.deviceUser.menu.includes(k)); - const { canceled, result: item } = await this.$root.dialog({ - type: null, - title: this.$t('addItem'), - select: { - items: [...menu.map(k => ({ - value: k, text: this.$t(this.menuDef[k].title) - })), ...[{ - value: '-', text: this.$t('divider') - }]] - }, - showCancelButton: true - }); - if (canceled) return; - this.items = [...this.splited, item].join('\n'); - this.save(); - }, - - save() { - this.$store.commit('deviceUser/setMenu', this.splited); - }, - - reset() { - this.$store.commit('deviceUser/setMenu', defaultDeviceUserSettings.menu); - this.items = this.$store.state.deviceUser.menu.join('\n'); - }, - }, -}); -</script> - -<style lang="scss" scoped> - -</style> diff --git a/src/client/pages/room/preview.vue b/src/client/pages/room/preview.vue index 22228cf8cb..b0e600d4fb 100644 --- a/src/client/pages/room/preview.vue +++ b/src/client/pages/room/preview.vue @@ -3,10 +3,11 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import * as THREE from 'three'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ data() { return { selected: null, diff --git a/src/client/pages/room/room.vue b/src/client/pages/room/room.vue index e20b9e2002..89e141c436 100644 --- a/src/client/pages/room/room.vue +++ b/src/client/pages/room/room.vue @@ -1,22 +1,14 @@ <template> <div class="hveuntkp"> - <portal to="avatar" v-if="user"><mk-avatar class="avatar" :user="user" :disable-preview="true"/></portal> - <portal to="title" v-if="user"> - <mfm - :text="$t('_rooms.roomOf', { user: user.name || user.username })" - :plain="true" :nowrap="true" :custom-emojis="user.emojis" :is-note="false" - /> - </portal> - - <div class="controller _card _vMargin" v-if="objectSelected"> + <div class="controller _section" v-if="objectSelected"> <div class="_content"> <p class="name">{{ selectedFurnitureName }}</p> - <x-preview ref="preview"/> + <XPreview ref="preview"/> <template v-if="selectedFurnitureInfo.props"> <div v-for="k in Object.keys(selectedFurnitureInfo.props)" :key="k"> <p>{{ k }}</p> <template v-if="selectedFurnitureInfo.props[k] === 'image'"> - <mk-button @click="chooseImage(k, $event)">{{ $t('_rooms.chooseImage') }}</mk-button> + <MkButton @click="chooseImage(k, $event)">{{ $t('_rooms.chooseImage') }}</MkButton> </template> <template v-else-if="selectedFurnitureInfo.props[k] === 'color'"> <input type="color" :value="selectedFurnitureProps ? selectedFurnitureProps[k] : null" @change="updateColor(k, $event)"/> @@ -25,54 +17,55 @@ </template> </div> <div class="_content"> - <mk-button inline @click="translate()" :primary="isTranslateMode"><fa :icon="faArrowsAlt"/> {{ $t('_rooms.translate') }}</mk-button> - <mk-button inline @click="rotate()" :primary="isRotateMode"><fa :icon="faUndo"/> {{ $t('_rooms.rotate') }}</mk-button> - <mk-button inline v-if="isTranslateMode || isRotateMode" @click="exit()"><fa :icon="faBan"/> {{ $t('_rooms.exit') }}</mk-button> + <MkButton inline @click="translate()" :primary="isTranslateMode"><Fa :icon="faArrowsAlt"/> {{ $t('_rooms.translate') }}</MkButton> + <MkButton inline @click="rotate()" :primary="isRotateMode"><Fa :icon="faUndo"/> {{ $t('_rooms.rotate') }}</MkButton> + <MkButton inline v-if="isTranslateMode || isRotateMode" @click="exit()"><Fa :icon="faBan"/> {{ $t('_rooms.exit') }}</MkButton> </div> <div class="_content"> - <mk-button @click="remove()"><fa :icon="faTrashAlt"/> {{ $t('_rooms.remove') }}</mk-button> + <MkButton @click="remove()"><Fa :icon="faTrashAlt"/> {{ $t('_rooms.remove') }}</MkButton> </div> </div> - <div class="menu _card _vMargin" v-if="isMyRoom"> + <div class="menu _section" v-if="isMyRoom"> <div class="_content"> - <mk-button @click="add()"><fa :icon="faBoxOpen"/> {{ $t('_rooms.addFurniture') }}</mk-button> + <MkButton @click="add()"><Fa :icon="faBoxOpen"/> {{ $t('_rooms.addFurniture') }}</MkButton> </div> <div class="_content"> - <mk-select :value="roomType" @input="updateRoomType($event)"> + <MkSelect :value="roomType" @update:value="updateRoomType($event)"> <template #label>{{ $t('_rooms.roomType') }}</template> <option value="default">{{ $t('_rooms._roomType.default') }}</option> <option value="washitsu">{{ $t('_rooms._roomType.washitsu') }}</option> - </mk-select> + </MkSelect> <label v-if="roomType === 'default'"> <span>{{ $t('_rooms.carpetColor') }}</span> <input type="color" :value="carpetColor" @change="updateCarpetColor($event)"/> </label> </div> <div class="_content"> - <mk-button inline :disabled="!changed" primary @click="save()"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> - <mk-button inline @click="clear()"><fa :icon="faBroom"/> {{ $t('_rooms.clear') }}</mk-button> + <MkButton inline :disabled="!changed" primary @click="save()"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> + <MkButton inline @click="clear()"><Fa :icon="faBroom"/> {{ $t('_rooms.clear') }}</MkButton> </div> </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; -import { Room } from '../../scripts/room/room'; +import { computed, defineComponent } from 'vue'; +import { Room } from '@/scripts/room/room'; import parseAcct from '../../../misc/acct/parse'; import XPreview from './preview.vue'; -const storeItems = require('../../scripts/room/furnitures.json5'); +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'; -import MkButton from '../../components/ui/button.vue'; -import MkSelect from '../../components/ui/select.vue'; -import { selectFile } from '../../scripts/select-file'; +import MkButton from '@/components/ui/button.vue'; +import MkSelect from '@/components/ui/select.vue'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; let room: Room; -export default Vue.extend({ +export default defineComponent({ components: { XPreview, MkButton, @@ -88,6 +81,12 @@ export default Vue.extend({ data() { return { + INFO: computed(() => this.user ? { + header: [{ + title: this.$t('room'), + avatar: this.user, + }], + } : null), user: null, objectSelected: false, selectedFurnitureName: null, @@ -106,13 +105,13 @@ export default Vue.extend({ async mounted() { window.addEventListener('beforeunload', this.beforeunload); - this.user = await this.$root.api('users/show', { + this.user = await os.api('users/show', { ...parseAcct(this.acct) }); this.isMyRoom = this.$store.getters.isSignedIn && (this.$store.state.i.id === this.user.id); - const roomInfo = await this.$root.api('room/show', { + const roomInfo = await os.api('room/show', { userId: this.user.id }); @@ -141,7 +140,7 @@ export default Vue.extend({ beforeRouteLeave(to, from, next) { if (this.changed) { - this.$root.dialog({ + os.dialog({ type: 'warning', text: this.$t('leaveConfirm'), showCancelButton: true @@ -157,7 +156,7 @@ export default Vue.extend({ } }, - beforeDestroy() { + beforeUnmount() { room.destroy(); window.removeEventListener('beforeunload', this.beforeunload); }, @@ -171,7 +170,7 @@ export default Vue.extend({ }, async add() { - const { canceled, result: id } = await this.$root.dialog({ + const { canceled, result: id } = await os.dialog({ type: null, title: this.$t('_rooms.addFurniture'), select: { @@ -194,16 +193,13 @@ export default Vue.extend({ }, save() { - this.$root.api('room/update', { + os.api('room/update', { room: room.getRoomInfo() }).then(() => { this.changed = false; - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); }).catch((e: any) => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e.message }); @@ -211,7 +207,7 @@ export default Vue.extend({ }, clear() { - this.$root.dialog({ + os.dialog({ type: 'warning', text: this.$t('_rooms.clearConfirm'), showCancelButton: true @@ -223,7 +219,7 @@ export default Vue.extend({ }, chooseImage(key, e) { - selectFile(this, e.currentTarget || e.target, null, false).then(file => { + selectFile(e.currentTarget || e.target, null, false).then(file => { room.updateProp(key, `/proxy/?${urlQuery({ url: file.thumbnailUrl })}`); this.$refs.preview.selected(room.getSelectedObject()); this.changed = true; @@ -285,7 +281,7 @@ export default Vue.extend({ position: relative; min-height: 500px; - > ::v-deep canvas { + > ::v-deep(canvas) { display: block; } } diff --git a/src/client/pages/scratchpad.vue b/src/client/pages/scratchpad.vue index d14d4452bf..7549ed32c6 100644 --- a/src/client/pages/scratchpad.vue +++ b/src/client/pages/scratchpad.vue @@ -1,28 +1,25 @@ <template> <div class=""> - <portal to="icon"><fa :icon="faTerminal"/></portal> - <portal to="title">{{ $t('scratchpad') }}</portal> - <div class="_panel"> - <prism-editor class="_code" v-model="code" :highlight="highlighter" :line-numbers="false"/> - <mk-button style="position: absolute; top: 8px; right: 8px;" @click="run()" primary><fa :icon="faPlay"/></mk-button> + <prism-editor class="_code" v-model:value="code" :highlight="highlighter" :line-numbers="false"/> + <MkButton style="position: absolute; top: 8px; right: 8px;" @click="run()" primary><Fa :icon="faPlay"/></MkButton> </div> - <mk-container :body-togglable="true"> - <template #header><fa fixed-width/>{{ $t('output') }}</template> + <MkContainer :body-togglable="true"> + <template #header><Fa fixed-width/>{{ $t('output') }}</template> <div class="bepmlvbi"> <div v-for="log in logs" class="log" :key="log.id" :class="{ print: log.print }">{{ log.text }}</div> </div> - </mk-container> + </MkContainer> - <section class="_card" style="margin-top: var(--margin);"> + <section class="_section" style="margin-top: var(--margin);"> <div class="_content">{{ $t('scratchpadDescription') }}</div> </section> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faTerminal, faPlay } from '@fortawesome/free-solid-svg-icons'; import 'prismjs'; import { highlight, languages } from 'prismjs/components/prism-core'; @@ -32,17 +29,12 @@ import 'prismjs/themes/prism-okaidia.css'; import { PrismEditor } from 'vue-prism-editor'; import 'vue-prism-editor/dist/prismeditor.min.css'; import { AiScript, parse, utils, values } from '@syuilo/aiscript'; -import MkContainer from '../components/ui/container.vue'; -import MkButton from '../components/ui/button.vue'; -import { createAiScriptEnv } from '../scripts/aiscript/api'; - -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('scratchpad') as string - }; - }, +import MkContainer from '@/components/ui/container.vue'; +import MkButton from '@/components/ui/button.vue'; +import { createAiScriptEnv } from '@/scripts/aiscript/api'; +import * as os from '@/os'; +export default defineComponent({ components: { MkContainer, MkButton, @@ -51,6 +43,12 @@ export default Vue.extend({ data() { return { + INFO: { + header: [{ + title: this.$t('scratchpad'), + icon: faTerminal, + }], + }, code: '', logs: [], faTerminal, faPlay @@ -73,12 +71,12 @@ export default Vue.extend({ methods: { async run() { this.logs = []; - const aiscript = new AiScript(createAiScriptEnv(this, { + const aiscript = new AiScript(createAiScriptEnv({ storageKey: 'scratchpad' }), { in: (q) => { return new Promise(ok => { - this.$root.dialog({ + os.dialog({ title: q, input: {} }).then(({ canceled, result: a }) => { @@ -109,7 +107,7 @@ export default Vue.extend({ try { ast = parse(this.code); } catch (e) { - this.$root.dialog({ + os.dialog({ type: 'error', text: 'Syntax error :(' }); @@ -118,7 +116,7 @@ export default Vue.extend({ try { await aiscript.exec(ast); } catch (e) { - this.$root.dialog({ + os.dialog({ type: 'error', text: e }); diff --git a/src/client/pages/search.vue b/src/client/pages/search.vue index c3e87c0d0c..7a395a964f 100644 --- a/src/client/pages/search.vue +++ b/src/client/pages/search.vue @@ -1,30 +1,30 @@ <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 class="_section"> + <div class="_content"> + <XNotes ref="notes" :pagination="pagination" @before="before" @after="after"/> + </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } 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 - }; - }, +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +export default defineComponent({ components: { XNotes }, data() { return { + INFO: { + header: [{ + title: this.$t('searchWith', { q: this.$route.query.q }), + icon: faSearch + }], + }, pagination: { endpoint: 'notes/search', limit: 10, @@ -32,7 +32,6 @@ export default Vue.extend({ query: this.$route.query.q, }) }, - faSearch }; }, diff --git a/src/client/pages/settings/api.vue b/src/client/pages/settings/api.vue new file mode 100644 index 0000000000..326ba90062 --- /dev/null +++ b/src/client/pages/settings/api.vue @@ -0,0 +1,59 @@ +<template> +<section class="_section"> + <div class="_content"> + <MkButton @click="generateToken">{{ $t('generateAccessToken') }}</MkButton> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faKey } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, MkInput + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: 'API', + icon: faKey + }] + }, + }; + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + async generateToken() { + os.popup(await import('@/components/token-generate-window.vue'), {}, { + done: async result => { + const { name, permissions } = result; + const { token } = await os.api('miauth/gen-token', { + session: null, + name: name, + permission: permissions, + }); + + os.dialog({ + type: 'success', + title: this.$t('token'), + text: token + }); + }, + }, 'closed'); + }, + } +}); +</script> diff --git a/src/client/pages/my-settings/drive.vue b/src/client/pages/settings/drive.vue index 7612c5011f..a7d623be37 100644 --- a/src/client/pages/my-settings/drive.vue +++ b/src/client/pages/settings/drive.vue @@ -1,21 +1,21 @@ <template> -<section class="uawsfosz _card"> - <div class="_title"><fa :icon="faCloud"/> {{ $t('drive') }}</div> +<section class="uawsfosz _section"> + <div class="_title"><Fa :icon="faCloud"/> {{ $t('drive') }}</div> <div class="_content"> <span>{{ $t('uploadFolder') }}: {{ uploadFolder ? uploadFolder.name : '-' }}</span> - <mk-button primary @click="chooseUploadFolder()"><fa :icon="faFolderOpen"/> {{ $t('selectFolder') }}</mk-button> + <MkButton primary @click="chooseUploadFolder()"><Fa :icon="faFolderOpen"/> {{ $t('selectFolder') }}</MkButton> </div> </section> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faCloud, faFolderOpen } from '@fortawesome/free-solid-svg-icons'; import { faClock, faEyeSlash, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; -import MkButton from '../../components/ui/button.vue'; -import { selectDriveFolder } from '../../scripts/select-drive-folder'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkButton, }, @@ -29,7 +29,7 @@ export default Vue.extend({ async created() { if (this.$store.state.settings.uploadFolder) { - this.uploadFolder = await this.$root.api('drive/folders/show', { + this.uploadFolder = await os.api('drive/folders/show', { folderId: this.$store.state.settings.uploadFolder }); } @@ -37,14 +37,11 @@ export default Vue.extend({ methods: { chooseUploadFolder() { - selectDriveFolder(this.$root, false).then(async folder => { + os.selectDriveFolder(false).then(async folder => { await this.$store.dispatch('settings/set', { key: 'uploadFolder', value: folder ? folder.id : null }); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); if (this.$store.state.settings.uploadFolder) { - this.uploadFolder = await this.$root.api('drive/folders/show', { + this.uploadFolder = await os.api('drive/folders/show', { folderId: this.$store.state.settings.uploadFolder }); } else { diff --git a/src/client/pages/settings/general.vue b/src/client/pages/settings/general.vue new file mode 100644 index 0000000000..80152c5e6a --- /dev/null +++ b/src/client/pages/settings/general.vue @@ -0,0 +1,219 @@ +<template> +<div class="_section"> + <section class="_card _vMargin"> + <div class="_title"><Fa :icon="faCog"/> {{ $t('general') }}</div> + <div class="_content"> + <div>{{ $t('whenServerDisconnected') }}</div> + <MkRadio v-model="serverDisconnectedBehavior" value="reload">{{ $t('_serverDisconnectedBehavior.reload') }}</MkRadio> + <MkRadio v-model="serverDisconnectedBehavior" value="dialog">{{ $t('_serverDisconnectedBehavior.dialog') }}</MkRadio> + <MkRadio v-model="serverDisconnectedBehavior" value="quiet">{{ $t('_serverDisconnectedBehavior.quiet') }}</MkRadio> + </div> + <div class="_content"> + <MkSwitch v-model:value="imageNewTab">{{ $t('openImageInNewTab') }}</MkSwitch> + <MkSwitch v-model:value="showFixedPostForm">{{ $t('showFixedPostForm') }}</MkSwitch> + <MkSwitch v-model:value="enableInfiniteScroll">{{ $t('enableInfiniteScroll') }}</MkSwitch> + <MkSwitch v-model:value="disablePagesScript">{{ $t('disablePagesScript') }}</MkSwitch> + </div> + <div class="_content"> + <div>{{ $t('chatOpenBehavior') }}</div> + <MkRadio v-model="chatOpenBehavior" value="page">{{ $t('showInPage') }}</MkRadio> + <MkRadio v-model="chatOpenBehavior" value="window">{{ $t('openInWindow') }}</MkRadio> + <MkRadio v-model="chatOpenBehavior" value="popout">{{ $t('popout') }}</MkRadio> + </div> + <div class="_content"> + <MkSelect v-model:value="lang"> + <template #label>{{ $t('uiLanguage') }}</template> + + <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> + </MkSelect> + </div> + </section> + + <section class="_card _vMargin"> + <div class="_title"><Fa :icon="faCog"/> {{ $t('appearance') }}</div> + <div class="_content"> + <MkSwitch v-model:value="disableAnimatedMfm">{{ $t('disableAnimatedMfm') }}</MkSwitch> + <MkSwitch v-model:value="reduceAnimation">{{ $t('reduceUiAnimation') }}</MkSwitch> + <MkSwitch v-model:value="useBlurEffectForModal">{{ $t('useBlurEffectForModal') }}</MkSwitch> + <MkSwitch v-model:value="useOsNativeEmojis"> + {{ $t('useOsNativeEmojis') }} + <template #desc><Mfm text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></template> + </MkSwitch> + </div> + <div class="_content"> + <div>{{ $t('fontSize') }}</div> + <MkRadio v-model="fontSize" value="small"><span style="font-size: 14px;">Aa</span></MkRadio> + <MkRadio v-model="fontSize" :value="null"><span style="font-size: 16px;">Aa</span></MkRadio> + <MkRadio v-model="fontSize" value="large"><span style="font-size: 18px;">Aa</span></MkRadio> + <MkRadio v-model="fontSize" value="veryLarge"><span style="font-size: 20px;">Aa</span></MkRadio> + </div> + </section> + + <section class="_card _vMargin"> + <div class="_title"><Fa :icon="faColumns"/> {{ $t('deck') }}</div> + <div class="_content"> + <MkSwitch v-model:value="deckAlwaysShowMainColumn"> + {{ $t('_deck.alwaysShowMainColumn') }} + </MkSwitch> + </div> + <div class="_content"> + <div>{{ $t('_deck.columnAlign') }}</div> + <MkRadio v-model="deckColumnAlign" value="left">{{ $t('left') }}</MkRadio> + <MkRadio v-model="deckColumnAlign" value="center">{{ $t('center') }}</MkRadio> + </div> + </section> + + <MkButton @click="cacheClear()" primary style="margin: var(--margin) auto;">{{ $t('cacheClear') }}</MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faImage, faCog, faColumns, faCogs } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkRadio from '@/components/ui/radio.vue'; +import MkRange from '@/components/ui/range.vue'; +import { langs } from '@/config'; +import { clientDb, set } from '@/db'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkSwitch, + MkSelect, + MkRadio, + MkRange, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('general'), + icon: faCogs + }] + }, + langs, + lang: localStorage.getItem('lang'), + fontSize: localStorage.getItem('fontSize'), + faImage, faCog, faColumns + } + }, + + computed: { + serverDisconnectedBehavior: { + get() { return this.$store.state.device.serverDisconnectedBehavior; }, + set(value) { this.$store.commit('device/set', { key: 'serverDisconnectedBehavior', value }); } + }, + + reduceAnimation: { + get() { return !this.$store.state.device.animation; }, + set(value) { this.$store.commit('device/set', { key: 'animation', value: !value }); } + }, + + useBlurEffectForModal: { + get() { return this.$store.state.device.useBlurEffectForModal; }, + set(value) { this.$store.commit('device/set', { key: 'useBlurEffectForModal', value: value }); } + }, + + disableAnimatedMfm: { + get() { return !this.$store.state.device.animatedMfm; }, + set(value) { this.$store.commit('device/set', { key: 'animatedMfm', value: !value }); } + }, + + useOsNativeEmojis: { + get() { return this.$store.state.device.useOsNativeEmojis; }, + set(value) { this.$store.commit('device/set', { key: 'useOsNativeEmojis', value }); } + }, + + imageNewTab: { + get() { return this.$store.state.device.imageNewTab; }, + set(value) { this.$store.commit('device/set', { key: 'imageNewTab', value }); } + }, + + disablePagesScript: { + get() { return this.$store.state.device.disablePagesScript; }, + set(value) { this.$store.commit('device/set', { key: 'disablePagesScript', value }); } + }, + + showFixedPostForm: { + get() { return this.$store.state.device.showFixedPostForm; }, + set(value) { this.$store.commit('device/set', { key: 'showFixedPostForm', value }); } + }, + + chatOpenBehavior: { + get() { return this.$store.state.device.chatOpenBehavior; }, + set(value) { this.$store.commit('device/set', { key: 'chatOpenBehavior', value }); } + }, + + enableInfiniteScroll: { + get() { return this.$store.state.device.enableInfiniteScroll; }, + set(value) { this.$store.commit('device/set', { key: 'enableInfiniteScroll', value }); } + }, + + deckAlwaysShowMainColumn: { + get() { return this.$store.state.device.deckAlwaysShowMainColumn; }, + set(value) { this.$store.commit('device/set', { key: 'deckAlwaysShowMainColumn', value }); } + }, + + deckColumnAlign: { + get() { return this.$store.state.device.deckColumnAlign; }, + set(value) { this.$store.commit('device/set', { key: 'deckColumnAlign', value }); } + }, + }, + + watch: { + lang() { + localStorage.setItem('lang', this.lang); + + return set('_version_', `changeLang-${(new Date()).toJSON()}`, clientDb.i18n) + .then(() => location.reload()) + .catch(() => { + os.dialog({ + type: 'error', + }); + }); + }, + + fontSize() { + if (this.fontSize == null) { + localStorage.removeItem('fontSize'); + } else { + localStorage.setItem('fontSize', this.fontSize); + } + location.reload(); + }, + + enableInfiniteScroll() { + location.reload() + }, + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + 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> diff --git a/src/client/pages/my-settings/import-export.vue b/src/client/pages/settings/import-export.vue index cc148d48d4..a5a0085277 100644 --- a/src/client/pages/my-settings/import-export.vue +++ b/src/client/pages/settings/import-export.vue @@ -1,29 +1,30 @@ <template> -<section class="_card"> - <div class="_title"><fa :icon="faBoxes"/> {{ $t('importAndExport') }}</div> +<section class="_section"> + <div class="_title"><Fa :icon="faBoxes"/> {{ $t('importAndExport') }}</div> <div class="_content"> - <mk-select v-model="exportTarget"> + <MkSelect v-model:value="exportTarget"> <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> + </MkSelect> + <MkButton inline @click="doExport()"><Fa :icon="faDownload"/> {{ $t('export') }}</MkButton> + <MkButton inline @click="doImport()" :disabled="!['following', 'user-lists'].includes(exportTarget)"><Fa :icon="faUpload"/> {{ $t('import') }}</MkButton> </div> <input ref="file" type="file" style="display: none;" @change="onChangeFile"/> </section> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } 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 { apiUrl } from '../../config'; +import MkButton from '@/components/ui/button.vue'; +import MkSelect from '@/components/ui/select.vue'; +import { apiUrl } from '@/config'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkButton, MkSelect, @@ -38,19 +39,19 @@ export default Vue.extend({ methods: { doExport() { - this.$root.api( + os.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({ + os.dialog({ type: 'info', text: this.$t('exportRequested') }); }).catch((e: any) => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e.message }); @@ -68,7 +69,7 @@ export default Vue.extend({ data.append('file', file); data.append('i', this.$store.state.i.token); - const dialog = this.$root.dialog({ + const dialog = os.dialog({ type: 'waiting', text: this.$t('uploading') + '...', showOkButton: false, @@ -85,7 +86,7 @@ export default Vue.extend({ this.reqImport(f); }) .catch(e => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e }); @@ -96,18 +97,18 @@ export default Vue.extend({ }, reqImport(file) { - this.$root.api( + os.api( this.exportTarget == 'following' ? 'i/import-following' : this.exportTarget == 'user-lists' ? 'i/import-user-lists' : null, { fileId: file.id }).then(() => { - this.$root.dialog({ + os.dialog({ type: 'info', text: this.$t('importRequested') }); }).catch((e: any) => { - this.$root.dialog({ + os.dialog({ type: 'error', text: e.message }); diff --git a/src/client/pages/settings/index.vue b/src/client/pages/settings/index.vue new file mode 100644 index 0000000000..4ca30ee686 --- /dev/null +++ b/src/client/pages/settings/index.vue @@ -0,0 +1,154 @@ +<template> +<div class="vvcocwet" :class="{ wide: !narrow }" ref="el"> + <div class="nav" v-if="!narrow || $route.name === 'settings'"> + <div class="menu"> + <div class="label">{{ $t('basicSettings') }}</div> + <router-link class="item" replace to="/settings/profile"><Fa :icon="faUser" fixed-width class="icon"/>{{ $t('profile') }}</router-link> + <router-link class="item" replace to="/settings/privacy"><Fa :icon="faLockOpen" fixed-width class="icon"/>{{ $t('privacy') }}</router-link> + <router-link class="item" replace to="/settings/reaction"><Fa :icon="faLaugh" fixed-width class="icon"/>{{ $t('reaction') }}</router-link> + <router-link class="item" replace to="/settings/notifications"><Fa :icon="faBell" fixed-width class="icon"/>{{ $t('notifications') }}</router-link> + <router-link class="item" replace to="/settings/integration"><Fa :icon="faShareAlt" fixed-width class="icon"/>{{ $t('integration') }}</router-link> + <router-link class="item" replace to="/settings/security"><Fa :icon="faLock" fixed-width class="icon"/>{{ $t('security') }}</router-link> + </div> + <div class="menu"> + <div class="label">{{ $t('clientSettings') }}</div> + <router-link class="item" replace to="/settings/general"><Fa :icon="faCogs" fixed-width class="icon"/>{{ $t('general') }}</router-link> + <router-link class="item" replace to="/settings/theme"><Fa :icon="faPalette" fixed-width class="icon"/>{{ $t('theme') }}</router-link> + <router-link class="item" replace to="/settings/sidebar"><Fa :icon="faListUl" fixed-width class="icon"/>{{ $t('sidebar') }}</router-link> + <router-link class="item" replace to="/settings/sounds"><Fa :icon="faMusic" fixed-width class="icon"/>{{ $t('sounds') }}</router-link> + <router-link class="item" replace to="/settings/plugins"><Fa :icon="faPlug" fixed-width class="icon"/>{{ $t('plugins') }}</router-link> + </div> + <div class="menu"> + <div class="label">{{ $t('otherSettings') }}</div> + <router-link class="item" replace to="/settings/mute-block"><Fa :icon="faBan" fixed-width class="icon"/>{{ $t('muteAndBlock') }}</router-link> + <router-link class="item" replace to="/settings/word-mute"><Fa :icon="faCommentSlash" fixed-width class="icon"/>{{ $t('wordMute') }}</router-link> + <router-link class="item" replace to="/settings/api"><Fa :icon="faKey" fixed-width class="icon"/>API</router-link> + <router-link class="item" replace to="/settings/other"><Fa :icon="faEllipsisH" fixed-width class="icon"/>{{ $t('other') }}</router-link> + </div> + <div class="menu"> + <button class="_button item" @click="logout">{{ $t('logout') }}</button> + </div> + </div> + <div class="main"> + <router-view v-slot="{ Component }"> + <transition :name="($store.state.device.animation && !narrow) ? 'view-slide' : ''" appear mode="out-in"> + <component :is="Component" @info="onInfo"/> + </transition> + </router-view> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, ref } from 'vue'; +import { faCog, faPalette, faPlug, faUser, faListUl, faLock, faCommentSlash, faMusic, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey } from '@fortawesome/free-solid-svg-icons'; +import { faLaugh, faBell } from '@fortawesome/free-regular-svg-icons'; +import { store } from '@/store'; +import { i18n } from '@/i18n'; + +export default defineComponent({ + setup(props, context) { + const INFO = ref({ + header: [{ + title: i18n.global.t('settings'), + icon: faCog + }] + }); + const narrow = ref(false); + const view = ref(null); + const el = ref(null); + const onInfo = (viewInfo) => { + INFO.value = viewInfo; + }; + + onMounted(() => { + narrow.value = el.value.offsetWidth < 650; + }); + + return { + INFO, + narrow, + view, + el, + onInfo, + logout: () => { + store.dispatch('logout'); + location.href = '/'; + }, + faPalette, faPlug, faUser, faListUl, faLock, faLaugh, faCommentSlash, faMusic, faBell, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.view-slide-enter-active, .view-slide-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.view-slide-enter-from, .view-slide-leave-to { + opacity: 0; + transform: translateX(32px); +} + +.vvcocwet { + max-width: 1000px; + margin: 0 auto; + + > .nav { + > .menu { + margin: 16px 0; + + > .label { + padding: 8px 32px; + font-size: 80%; + opacity: 0.7; + } + + > .item { + display: block; + width: 100%; + box-sizing: border-box; + padding: 0 32px; + line-height: 48px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + //background: var(--panel); + //border-bottom: solid 1px var(--divider); + transition: padding 0.2s ease, color 0.1s ease; + + &:first-of-type { + //border-top: solid 1px var(--divider); + } + + &.router-link-active { + color: var(--accent); + padding-left: 42px; + } + + &:hover { + text-decoration: none; + padding-left: 42px; + } + + > .icon { + margin-right: 0.5em; + } + } + } + } + + &.wide { + display: flex; + + > .nav { + width: 30%; + max-width: 260px; + } + + > .main { + flex: 1; + } + } +} +</style> diff --git a/src/client/pages/my-settings/integration.vue b/src/client/pages/settings/integration.vue index 2d6e57e22c..4f07417160 100644 --- a/src/client/pages/my-settings/integration.vue +++ b/src/client/pages/settings/integration.vue @@ -1,44 +1,51 @@ <template> -<section class="_card" v-if="enableTwitterIntegration || enableDiscordIntegration || enableGithubIntegration"> - <div class="_title"><fa :icon="faShareAlt"/> {{ $t('integration') }}</div> - +<section class="_section" v-if="enableTwitterIntegration || enableDiscordIntegration || enableGithubIntegration"> <div class="_content" v-if="enableTwitterIntegration"> - <header><fa :icon="faTwitter"/> Twitter</header> + <header><Fa :icon="faTwitter"/> Twitter</header> <p v-if="integrations.twitter">{{ $t('connectedTo') }}: <a :href="`https://twitter.com/${integrations.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ integrations.twitter.screenName }}</a></p> - <mk-button v-if="integrations.twitter" @click="disconnectTwitter">{{ $t('disconnectSerice') }}</mk-button> - <mk-button v-else @click="connectTwitter">{{ $t('connectSerice') }}</mk-button> + <MkButton v-if="integrations.twitter" @click="disconnectTwitter">{{ $t('disconnectSerice') }}</MkButton> + <MkButton v-else @click="connectTwitter">{{ $t('connectSerice') }}</MkButton> </div> <div class="_content" v-if="enableDiscordIntegration"> - <header><fa :icon="faDiscord"/> Discord</header> + <header><Fa :icon="faDiscord"/> Discord</header> <p v-if="integrations.discord">{{ $t('connectedTo') }}: <a :href="`https://discordapp.com/users/${integrations.discord.id}`" rel="nofollow noopener" target="_blank">@{{ integrations.discord.username }}#{{ integrations.discord.discriminator }}</a></p> - <mk-button v-if="integrations.discord" @click="disconnectDiscord">{{ $t('disconnectSerice') }}</mk-button> - <mk-button v-else @click="connectDiscord">{{ $t('connectSerice') }}</mk-button> + <MkButton v-if="integrations.discord" @click="disconnectDiscord">{{ $t('disconnectSerice') }}</MkButton> + <MkButton v-else @click="connectDiscord">{{ $t('connectSerice') }}</MkButton> </div> <div class="_content" v-if="enableGithubIntegration"> - <header><fa :icon="faGithub"/> GitHub</header> + <header><Fa :icon="faGithub"/> GitHub</header> <p v-if="integrations.github">{{ $t('connectedTo') }}: <a :href="`https://github.com/${integrations.github.login}`" rel="nofollow noopener" target="_blank">@{{ integrations.github.login }}</a></p> - <mk-button v-if="integrations.github" @click="disconnectGithub">{{ $t('disconnectSerice') }}</mk-button> - <mk-button v-else @click="connectGithub">{{ $t('connectSerice') }}</mk-button> + <MkButton v-if="integrations.github" @click="disconnectGithub">{{ $t('disconnectSerice') }}</MkButton> + <MkButton v-else @click="connectGithub">{{ $t('connectSerice') }}</MkButton> </div> </section> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faShareAlt } from '@fortawesome/free-solid-svg-icons'; import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons'; -import { apiUrl } from '../../config'; -import MkButton from '../../components/ui/button.vue'; +import { apiUrl } from '@/config'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkButton }, + emits: ['info'], + data() { return { + INFO: { + header: [{ + title: this.$t('integration'), + icon: faShareAlt + }] + }, apiUrl, twitterForm: null, discordForm: null, @@ -67,6 +74,8 @@ export default Vue.extend({ }, mounted() { + this.$emit('info', this.INFO); + document.cookie = `igi=${this.$store.state.i.token}; path=/;` + ` max-age=31536000;` + (document.location.protocol.startsWith('https') ? ' secure' : ''); diff --git a/src/client/pages/settings/mute-block.vue b/src/client/pages/settings/mute-block.vue new file mode 100644 index 0000000000..5a08a8caae --- /dev/null +++ b/src/client/pages/settings/mute-block.vue @@ -0,0 +1,93 @@ +<template> +<section class="rrfwjxfl _section"> + <MkTab v-model:value="tab" :items="[{ label: $t('mutedUsers'), value: 'mute' }, { label: $t('blockedUsers'), value: 'block' }]" style="margin-bottom: var(--margin);"/> + <div class="_content" v-if="tab === 'mute'"> + <MkPagination :pagination="mutingPagination" class="muting"> + <template #empty><MkInfo>{{ $t('noUsers') }}</MkInfo></template> + <template #default="{items}"> + <div class="user" v-for="mute in items" :key="mute.id"> + <router-link class="name" :to="userPage(mute.mutee)"> + <MkAcct :user="mute.mutee"/> + </router-link> + </div> + </template> + </MkPagination> + </div> + <div class="_content" v-if="tab === 'block'"> + <MkPagination :pagination="blockingPagination" class="blocking"> + <template #empty><MkInfo>{{ $t('noUsers') }}</MkInfo></template> + <template #default="{items}"> + <div class="user" v-for="block in items" :key="block.id"> + <router-link class="name" :to="userPage(block.blockee)"> + <MkAcct :user="block.blockee"/> + </router-link> + </div> + </template> + </MkPagination> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faBan } from '@fortawesome/free-solid-svg-icons'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkTab from '@/components/tab.vue'; +import MkInfo from '@/components/ui/info.vue'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkPagination, + MkTab, + MkInfo, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('muteAndBlock'), + icon: faBan + }] + }, + tab: 'mute', + mutingPagination: { + endpoint: 'mute/list', + limit: 10, + }, + blockingPagination: { + endpoint: 'blocking/list', + limit: 10, + }, + } + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + userPage + } +}); +</script> + +<style lang="scss" scoped> +.rrfwjxfl { + > ._content { + max-height: 350px; + overflow: auto; + + > .muting, + > .blocking { + > .empty { + opacity: 0.5 !important; + } + } + } +} +</style> diff --git a/src/client/pages/settings/notifications.vue b/src/client/pages/settings/notifications.vue new file mode 100644 index 0000000000..98dc85ea52 --- /dev/null +++ b/src/client/pages/settings/notifications.vue @@ -0,0 +1,93 @@ +<template> +<div> + <div class="_section"> + <MkButton full primary @click="configure"><Fa :icon="faCog"/> {{ $t('notificationSetting') }}</MkButton> + </div> + <div class="_section"> + <div class="_card"> + <div class="_content"> + <MkSwitch v-model:value="$store.state.i.autoWatch" @update:value="onChangeAutoWatch"> + {{ $t('autoNoteWatch') }}<template #desc>{{ $t('autoNoteWatchDescription') }}</template> + </MkSwitch> + </div> + </div> + </div> + <div class="_section"> + <MkButton full @click="readAllNotifications">{{ $t('markAsReadAllNotifications') }}</MkButton> + <MkButton full @click="readAllUnreadNotes">{{ $t('markAsReadAllUnreadNotes') }}</MkButton> + <MkButton full @click="readAllMessagingMessages">{{ $t('markAsReadAllTalkMessages') }}</MkButton> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faCog } from '@fortawesome/free-solid-svg-icons'; +import { faBell } from '@fortawesome/free-regular-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import { notificationTypes } from '../../../types'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkSwitch, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('notifications'), + icon: faBell + }] + }, + faCog + } + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + onChangeAutoWatch(v) { + os.api('i/update', { + autoWatch: v + }); + }, + + readAllUnreadNotes() { + os.api('i/read-all-unread-notes'); + }, + + readAllMessagingMessages() { + os.api('i/read-all-messaging-messages'); + }, + + readAllNotifications() { + os.api('notifications/mark-all-as-read'); + }, + + async configure() { + const includingTypes = notificationTypes.filter(x => !this.$store.state.i.mutingNotificationTypes.includes(x)); + os.popup(await import('@/components/notification-setting-window.vue'), { + includingTypes, + showGlobalToggle: false, + }, { + done: async (res) => { + const { includingTypes: value } = res; + await os.apiWithDialog('i/update', { + mutingNotificationTypes: notificationTypes.filter(x => !value.includes(x)), + }).then(i => { + this.$store.state.i.mutingNotificationTypes = i.mutingNotificationTypes; + }); + } + }, 'closed'); + }, + } +}); +</script> diff --git a/src/client/pages/settings/other.vue b/src/client/pages/settings/other.vue new file mode 100644 index 0000000000..ebc5644162 --- /dev/null +++ b/src/client/pages/settings/other.vue @@ -0,0 +1,51 @@ +<template> +<div class="_section"> + <div class="_card"> + <div class="_content"> + <MkSwitch v-model:value="$store.state.i.injectFeaturedNote" @update:value="onChangeInjectFeaturedNote"> + {{ $t('showFeaturedNotesInTimeline') }} + </MkSwitch> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faEllipsisH } from '@fortawesome/free-solid-svg-icons'; +import MkSelect from '@/components/ui/select.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkSelect, + MkSwitch, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('other'), + icon: faEllipsisH + }] + }, + } + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + onChangeInjectFeaturedNote(v) { + os.api('i/update', { + injectFeaturedNote: v + }); + }, + } +}); +</script> diff --git a/src/client/pages/preferences/plugins.vue b/src/client/pages/settings/plugins.vue index 10f86de1e4..246624ddd4 100644 --- a/src/client/pages/preferences/plugins.vue +++ b/src/client/pages/settings/plugins.vue @@ -1,25 +1,25 @@ <template> -<section class="_card"> - <div class="_title"><fa :icon="faPlug"/> {{ $t('plugins') }}</div> +<section class="_section"> + <div class="_title"><Fa :icon="faPlug"/> {{ $t('plugins') }}</div> <div class="_content"> <details> - <summary><fa :icon="faDownload"/> {{ $t('install') }}</summary> - <mk-info warn>{{ $t('pluginInstallWarn') }}</mk-info> - <mk-textarea v-model="script" tall> + <summary><Fa :icon="faDownload"/> {{ $t('install') }}</summary> + <MkInfo warn>{{ $t('pluginInstallWarn') }}</MkInfo> + <MkTextarea v-model:value="script" tall> <span>{{ $t('script') }}</span> - </mk-textarea> - <mk-button @click="install()" primary><fa :icon="faSave"/> {{ $t('install') }}</mk-button> + </MkTextarea> + <MkButton @click="install()" primary><Fa :icon="faSave"/> {{ $t('install') }}</MkButton> </details> </div> <div class="_content"> <details> - <summary><fa :icon="faFolderOpen"/> {{ $t('manage') }}</summary> - <mk-select v-model="selectedPluginId"> + <summary><Fa :icon="faFolderOpen"/> {{ $t('manage') }}</summary> + <MkSelect v-model:value="selectedPluginId"> <option v-for="x in $store.state.deviceUser.plugins" :value="x.id" :key="x.id">{{ x.name }}</option> - </mk-select> + </MkSelect> <template v-if="selectedPlugin"> <div style="margin: -8px 0 8px 0;"> - <mk-switch :value="selectedPlugin.active" @change="changeActive(selectedPlugin, $event)">{{ $t('makeActive') }}</mk-switch> + <MkSwitch :value="selectedPlugin.active" @update:value="changeActive(selectedPlugin, $event)">{{ $t('makeActive') }}</MkSwitch> </div> <div class="_keyValue"> <div>{{ $t('version') }}:</div> @@ -34,8 +34,8 @@ <div>{{ selectedPlugin.description }}</div> </div> <div style="margin-top: 8px;"> - <mk-button @click="config()" inline v-if="selectedPlugin.config"><fa :icon="faCog"/> {{ $t('settings') }}</mk-button> - <mk-button @click="uninstall()" inline><fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</mk-button> + <MkButton @click="config()" inline v-if="selectedPlugin.config"><Fa :icon="faCog"/> {{ $t('settings') }}</MkButton> + <MkButton @click="uninstall()" inline><Fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</MkButton> </div> </template> </details> @@ -44,18 +44,19 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { AiScript, parse } from '@syuilo/aiscript'; import { serialize } from '@syuilo/aiscript/built/serializer'; import { v4 as uuid } from 'uuid'; import { faPlug, faSave, faTrashAlt, faFolderOpen, faDownload, faCog } from '@fortawesome/free-solid-svg-icons'; -import MkButton from '../../components/ui/button.vue'; -import MkTextarea from '../../components/ui/textarea.vue'; -import MkSelect from '../../components/ui/select.vue'; -import MkInfo from '../../components/ui/info.vue'; -import MkSwitch from '../../components/ui/switch.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkInfo from '@/components/ui/info.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkButton, MkTextarea, @@ -85,7 +86,7 @@ export default Vue.extend({ try { ast = parse(this.script); } catch (e) { - this.$root.dialog({ + os.dialog({ type: 'error', text: 'Syntax error :(' }); @@ -93,7 +94,7 @@ export default Vue.extend({ } const meta = AiScript.collectMetadata(ast); if (meta == null) { - this.$root.dialog({ + os.dialog({ type: 'error', text: 'No metadata found :(' }); @@ -101,7 +102,7 @@ export default Vue.extend({ } const data = meta.get(null); if (data == null) { - this.$root.dialog({ + os.dialog({ type: 'error', text: 'No metadata found :(' }); @@ -109,7 +110,7 @@ export default Vue.extend({ } const { name, version, author, description, permissions, config } = data; if (name == null || version == null || author == null) { - this.$root.dialog({ + os.dialog({ type: 'error', text: 'Required property not found :(' }); @@ -117,20 +118,23 @@ export default Vue.extend({ } const token = permissions == null || permissions.length === 0 ? null : await new Promise(async (res, rej) => { - this.$root.new(await import('../../components/token-generate-window.vue').then(m => m.default), { + os.popup(await import('@/components/token-generate-window.vue'), { title: this.$t('tokenRequested'), information: this.$t('pluginTokenRequestedDescription'), initialName: name, initialPermissions: permissions - }).$on('ok', async ({ name, permissions }) => { - const { token } = await this.$root.api('miauth/gen-token', { - session: null, - name: name, - permission: permissions, - }); + }, { + done: async result => { + const { name, permissions } = result; + const { token } = await os.api('miauth/gen-token', { + session: null, + name: name, + permission: permissions, + }); - res(token); - }); + res(token); + } + }, 'closed'); }); this.$store.commit('deviceUser/installPlugin', { @@ -142,10 +146,7 @@ export default Vue.extend({ ast: serialize(ast) }); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); this.$nextTick(() => { location.reload(); @@ -154,10 +155,7 @@ export default Vue.extend({ uninstall() { this.$store.commit('deviceUser/uninstallPlugin', this.selectedPluginId); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); this.$nextTick(() => { location.reload(); }); @@ -170,7 +168,7 @@ export default Vue.extend({ config[key].default = this.selectedPlugin.configData[key]; } - const { canceled, result } = await this.$root.form(this.selectedPlugin.name, config); + const { canceled, result } = await os.form(this.selectedPlugin.name, config); if (canceled) return; this.$store.commit('deviceUser/configPlugin', { diff --git a/src/client/pages/settings/privacy.vue b/src/client/pages/settings/privacy.vue new file mode 100644 index 0000000000..a92baca9d9 --- /dev/null +++ b/src/client/pages/settings/privacy.vue @@ -0,0 +1,86 @@ +<template> +<div class="_section"> + <div class="_card"> + <div class="_content"> + <MkSwitch v-model:value="isLocked" @update:value="save()">{{ $t('makeFollowManuallyApprove') }}</MkSwitch> + <MkSwitch v-model:value="autoAcceptFollowed" v-if="isLocked" @update:value="save()">{{ $t('autoAcceptFollowed') }}</MkSwitch> + </div> + <div class="_content"> + <MkSwitch v-model:value="rememberNoteVisibility" @update:value="save()">{{ $t('rememberNoteVisibility') }}</MkSwitch> + <MkSelect v-model:value="defaultNoteVisibility" style="margin-bottom: 8px;" v-if="!rememberNoteVisibility"> + <template #label>{{ $t('defaultNoteVisibility') }}</template> + <option value="public">{{ $t('_visibility.public') }}</option> + <option value="home">{{ $t('_visibility.home') }}</option> + <option value="followers">{{ $t('_visibility.followers') }}</option> + <option value="specified">{{ $t('_visibility.specified') }}</option> + </MkSelect> + <MkSwitch v-model:value="defaultNoteLocalOnly" v-if="!rememberNoteVisibility">{{ $t('_visibility.localOnly') }}</MkSwitch> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faLockOpen } from '@fortawesome/free-solid-svg-icons'; +import MkSelect from '@/components/ui/select.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkSelect, + MkSwitch, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('privacy'), + icon: faLockOpen + }] + }, + isLocked: false, + autoAcceptFollowed: false, + } + }, + + computed: { + defaultNoteVisibility: { + get() { return this.$store.state.settings.defaultNoteVisibility; }, + set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); } + }, + + defaultNoteLocalOnly: { + get() { return this.$store.state.settings.defaultNoteLocalOnly; }, + set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteLocalOnly', 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; + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + save() { + os.api('i/update', { + isLocked: !!this.isLocked, + autoAcceptFollowed: !!this.autoAcceptFollowed, + }); + } + } +}); +</script> diff --git a/src/client/pages/my-settings/profile.vue b/src/client/pages/settings/profile.vue index 16bba7a270..4444b4f484 100644 --- a/src/client/pages/my-settings/profile.vue +++ b/src/client/pages/settings/profile.vue @@ -1,71 +1,74 @@ <template> -<section class="llvierxe _card"> - <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"> - <div class="header" :style="{ backgroundImage: $store.state.i.bannerUrl ? `url(${ $store.state.i.bannerUrl })` : null }" @click="changeBanner"> - <mk-avatar class="avatar" :user="$store.state.i" :disable-preview="true" :disable-link="true" @click.stop="changeAvatar"/> - </div> - - <mk-input v-model="name" :max="30"> - <span>{{ $t('_profile.name') }}</span> - </mk-input> +<div class="_section"> + <div class="llvierxe _card"> + <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"> + <div class="header" :style="{ backgroundImage: $store.state.i.bannerUrl ? `url(${ $store.state.i.bannerUrl })` : null }" @click="changeBanner"> + <MkAvatar class="avatar" :user="$store.state.i" :disable-preview="true" :disable-link="true" @click.stop="changeAvatar"/> + </div> + + <MkInput v-model:value="name" :max="30"> + <span>{{ $t('_profile.name') }}</span> + </MkInput> - <mk-textarea v-model="description" :max="500"> - <span>{{ $t('_profile.description') }}</span> - <template #desc>{{ $t('_profile.youCanIncludeHashtags') }}</template> - </mk-textarea> + <MkTextarea v-model:value="description" :max="500"> + <span>{{ $t('_profile.description') }}</span> + <template #desc>{{ $t('_profile.youCanIncludeHashtags') }}</template> + </MkTextarea> - <mk-input v-model="location"> - <span>{{ $t('location') }}</span> - <template #prefix><fa :icon="faMapMarkerAlt"/></template> - </mk-input> + <MkInput v-model:value="location"> + <span>{{ $t('location') }}</span> + <template #prefix><Fa :icon="faMapMarkerAlt"/></template> + </MkInput> - <mk-input v-model="birthday" type="date"> - <template #title>{{ $t('birthday') }}</template> - <template #prefix><fa :icon="faBirthdayCake"/></template> - </mk-input> + <MkInput v-model:value="birthday" type="date"> + <template #title>{{ $t('birthday') }}</template> + <template #prefix><Fa :icon="faBirthdayCake"/></template> + </MkInput> - <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> + <details class="fields"> + <summary>{{ $t('_profile.metadata') }}</summary> + <div class="row"> + <MkInput v-model:value="fieldName0">{{ $t('_profile.metadataLabel') }}</MkInput> + <MkInput v-model:value="fieldValue0">{{ $t('_profile.metadataContent') }}</MkInput> + </div> + <div class="row"> + <MkInput v-model:value="fieldName1">{{ $t('_profile.metadataLabel') }}</MkInput> + <MkInput v-model:value="fieldValue1">{{ $t('_profile.metadataContent') }}</MkInput> + </div> + <div class="row"> + <MkInput v-model:value="fieldName2">{{ $t('_profile.metadataLabel') }}</MkInput> + <MkInput v-model:value="fieldValue2">{{ $t('_profile.metadataContent') }}</MkInput> + </div> + <div class="row"> + <MkInput v-model:value="fieldName3">{{ $t('_profile.metadataLabel') }}</MkInput> + <MkInput v-model:value="fieldValue3">{{ $t('_profile.metadataContent') }}</MkInput> + </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> + <MkSwitch v-model:value="isBot">{{ $t('flagAsBot') }}</MkSwitch> + <MkSwitch v-model:value="isCat">{{ $t('flagAsCat') }}</MkSwitch> + </div> + <div class="_footer"> + <MkButton @click="save(true)" primary><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> + </div> </div> -</section> +</div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faUnlockAlt, faCogs, 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 { host } from '../../config'; -import { selectFile } from '../../scripts/select-file'; +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 { host } from '@/config'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkButton, MkInput, @@ -73,8 +76,16 @@ export default Vue.extend({ MkSwitch, }, + emits: ['info'], + data() { return { + INFO: { + header: [{ + title: this.$t('profile'), + icon: faUser + }] + }, host, name: null, description: null, @@ -117,18 +128,22 @@ export default Vue.extend({ this.fieldValue3 = this.$store.state.i.fields[3] ? this.$store.state.i.fields[3].value : null; }, + mounted() { + this.$emit('info', this.INFO); + }, + methods: { changeAvatar(e) { - selectFile(this, e.currentTarget || e.target, this.$t('avatar')).then(file => { - this.$root.api('i/update', { + selectFile(e.currentTarget || e.target, this.$t('avatar')).then(file => { + os.api('i/update', { avatarId: file.id, }); }); }, changeBanner(e) { - selectFile(this, e.currentTarget || e.target, this.$t('banner')).then(file => { - this.$root.api('i/update', { + selectFile(e.currentTarget || e.target, this.$t('banner')).then(file => { + os.api('i/update', { bannerId: file.id, }); }); @@ -144,7 +159,7 @@ export default Vue.extend({ this.saving = true; - this.$root.api('i/update', { + os.api('i/update', { name: this.name || null, description: this.description || null, location: this.location || null, @@ -160,14 +175,11 @@ export default Vue.extend({ this.$store.state.i.bannerUrl = i.bannerUrl; if (notify) { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); } }).catch(err => { this.saving = false; - this.$root.dialog({ + os.dialog({ type: 'error', text: err.id }); diff --git a/src/client/pages/settings/reaction.vue b/src/client/pages/settings/reaction.vue new file mode 100644 index 0000000000..683cf6dfbe --- /dev/null +++ b/src/client/pages/settings/reaction.vue @@ -0,0 +1,95 @@ +<template> +<div class="_section"> + <div class="_card"> + <div class="_title"><Fa :icon="faLaugh"/> {{ $t('reaction') }}</div> + <div class="_content"> + <MkInput v-model:value="reactions" style="font-family: 'Segoe UI Emoji', 'Noto Color Emoji', Roboto, HelveticaNeue, Arial, sans-serif"> + {{ $t('reaction') }}<template #desc>{{ $t('reactionSettingDescription') }} <button class="_textButton" @click="chooseEmoji">{{ $t('chooseEmoji') }}</button></template> + </MkInput> + <MkButton inline @click="setDefault"><Fa :icon="faUndo"/> {{ $t('default') }}</MkButton> + </div> + <div class="_footer"> + <MkButton @click="save()" primary inline :disabled="!changed"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> + <MkButton inline @click="preview"><Fa :icon="faEye"/> {{ $t('preview') }}</MkButton> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faLaugh, faSave, faEye } from '@fortawesome/free-regular-svg-icons'; +import { faUndo } from '@fortawesome/free-solid-svg-icons'; +import MkInput from '@/components/ui/input.vue'; +import MkButton from '@/components/ui/button.vue'; +import { emojiRegexWithCustom } from '../../../misc/emoji-regex'; +import { defaultSettings } from '@/store'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkInput, + MkButton, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('reaction'), + icon: faLaugh + }] + }, + reactions: this.$store.state.settings.reactions.join(''), + changed: false, + faLaugh, faSave, faEye, faUndo + } + }, + + computed: { + splited(): any { + return this.reactions.match(emojiRegexWithCustom); + }, + }, + + watch: { + reactions: { + handler() { + this.changed = true; + }, + deep: true + } + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + save() { + this.$store.dispatch('settings/set', { key: 'reactions', value: this.splited }); + this.changed = false; + }, + + async preview(ev) { + os.popup(await import('@/components/reaction-picker.vue'), { + reactions: this.splited, + showFocus: false, + src: ev.currentTarget || ev.target, + }, {}, 'closed'); + }, + + setDefault() { + this.reactions = defaultSettings.reactions.join(''); + }, + + async chooseEmoji(ev) { + os.pickEmoji(ev.currentTarget || ev.target).then(emoji => { + this.reactions += emoji; + }); + } + } +}); +</script> diff --git a/src/client/pages/my-settings/2fa.vue b/src/client/pages/settings/security.2fa.vue index 58ba03c41c..22b3878445 100644 --- a/src/client/pages/my-settings/2fa.vue +++ b/src/client/pages/settings/security.2fa.vue @@ -1,11 +1,11 @@ <template> <section class="_card"> - <div class="_title"><fa :icon="faLock"/> {{ $t('twoStepAuthentication') }}</div> + <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> + <MkButton v-if="!data && !$store.state.i.twoFactorEnabled" @click="register">{{ $t('_2fa.registerDevice') }}</MkButton> <template v-if="$store.state.i.twoFactorEnabled"> <p>{{ $t('_2fa.alreadyRegistered') }}</p> - <mk-button @click="unregister">{{ $t('unregister') }}</mk-button> + <MkButton @click="unregister">{{ $t('unregister') }}</MkButton> <template v-if="supportsCredentials"> <hr class="totp-method-sep"> @@ -15,29 +15,29 @@ <div class="key-list"> <div class="key" v-for="key in $store.state.i.securityKeysList"> <h3>{{ key.name }}</h3> - <div class="last-used">{{ $t('lastUsed') }}<mk-time :time="key.lastUsed"/></div> - <mk-button @click="unregisterKey(key)">{{ $t('unregister') }}</mk-button> + <div class="last-used">{{ $t('lastUsed') }}<MkTime :time="key.lastUsed"/></div> + <MkButton @click="unregisterKey(key)">{{ $t('unregister') }}</MkButton> </div> </div> - <mk-switch v-model="usePasswordLessLogin" @change="updatePasswordLessLogin" v-if="$store.state.i.securityKeysList.length > 0">{{ $t('passwordLessLogin') }}</mk-switch> + <MkSwitch v-model:value="usePasswordLessLogin" @update:value="updatePasswordLessLogin" v-if="$store.state.i.securityKeysList.length > 0">{{ $t('passwordLessLogin') }}</MkSwitch> - <mk-info warn v-if="registration && registration.error">{{ $t('error') }} {{ registration.error }}</mk-info> - <mk-button v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('_2fa.registerKey') }}</mk-button> + <MkInfo warn v-if="registration && registration.error">{{ $t('error') }} {{ registration.error }}</MkInfo> + <MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('_2fa.registerKey') }}</MkButton> <ol v-if="registration && !registration.error"> <li v-if="registration.stage >= 0"> {{ $t('tapSecurityKey') }} - <fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 0" /> + <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"> + <MkForm :disabled="registration.stage != 1 || registration.saving"> + <MkInput v-model:value="keyName" :max="30"> <span>{{ $t('securityKeyName') }}</span> - </mk-input> - <mk-button @click="registerKey" :disabled="keyName.length == 0">{{ $t('registerSecurityKey') }}</mk-button> - <fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 1" /> - </mk-form> + </MkInput> + <MkButton @click="registerKey" :disabled="keyName.length == 0">{{ $t('registerSecurityKey') }}</MkButton> + <Fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 1" /> + </MkForm> </li> </ol> </template> @@ -45,34 +45,39 @@ <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" class="_link">Authy</a> - <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" place="b" class="_link">Google Authenticator</a> - </i18n> + <i18n-t keypath="_2fa.step1" tag="span"> + <template #a> + <a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a> + </template> + <template #b> + <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a> + </template> + </i18n-t> </li> <li>{{ $t('_2fa.step2') }}<br><img :src="data.qr"></li> <li>{{ $t('_2fa.step3') }}<br> - <mk-input v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false">{{ $t('token') }}</mk-input> - <mk-button primary @click="submit">{{ $t('done') }}</mk-button> + <MkInput v-model:value="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false">{{ $t('token') }}</MkInput> + <MkButton primary @click="submit">{{ $t('done') }}</MkButton> </li> </ol> - <mk-info>{{ $t('_2fa.step4') }}</mk-info> + <MkInfo>{{ $t('_2fa.step4') }}</MkInfo> </div> </div> </section> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faLock } from '@fortawesome/free-solid-svg-icons'; -import { hostname } from '../../config'; -import { byteify, hexify, stringify } from '../../scripts/2fa'; -import MkButton from '../../components/ui/button.vue'; -import MkInfo from '../../components/ui/info.vue'; -import MkInput from '../../components/ui/input.vue'; -import MkSwitch from '../../components/ui/switch.vue'; +import { hostname } from '@/config'; +import { byteify, hexify, stringify } from '@/scripts/2fa'; +import MkButton from '@/components/ui/button.vue'; +import MkInfo from '@/components/ui/info.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkButton, MkInfo, MkInput, MkSwitch }, @@ -89,14 +94,14 @@ export default Vue.extend({ }, methods: { register() { - this.$root.dialog({ + os.dialog({ title: this.$t('password'), input: { type: 'password' } }).then(({ canceled, result: password }) => { if (canceled) return; - this.$root.api('i/2fa/register', { + os.api('i/2fa/register', { password: password }).then(data => { this.data = data; @@ -105,48 +110,42 @@ export default Vue.extend({ }, unregister() { - this.$root.dialog({ + os.dialog({ title: this.$t('password'), input: { type: 'password' } }).then(({ canceled, result: password }) => { if (canceled) return; - this.$root.api('i/2fa/unregister', { + os.api('i/2fa/unregister', { password: password }).then(() => { this.usePasswordLessLogin = false; this.updatePasswordLessLogin(); }).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); this.$store.state.i.twoFactorEnabled = false; }); }); }, submit() { - this.$root.api('i/2fa/done', { + os.api('i/2fa/done', { token: this.token }).then(() => { - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); this.$store.state.i.twoFactorEnabled = true; }).catch(e => { - this.$root.dialog({ + os.dialog({ type: 'error', - iconOnly: true, autoClose: true + text: e }); }); }, registerKey() { this.registration.saving = true; - this.$root.api('i/2fa/key-done', { + os.api('i/2fa/key-done', { password: this.registration.password, name: this.keyName, challengeId: this.registration.challengeId, @@ -156,45 +155,39 @@ export default Vue.extend({ }).then(key => { this.registration = null; key.lastUsed = new Date(); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); }) }, unregisterKey(key) { - this.$root.dialog({ + os.dialog({ title: this.$t('password'), input: { type: 'password' } }).then(({ canceled, result: password }) => { if (canceled) return; - return this.$root.api('i/2fa/remove-key', { + return os.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 - }); + os.success(); }); }); }, addSecurityKey() { - this.$root.dialog({ + os.dialog({ title: this.$t('password'), input: { type: 'password' } }).then(({ canceled, result: password }) => { if (canceled) return; - this.$root.api('i/2fa/register-key', { + os.api('i/2fa/register-key', { password }).then(registration => { this.registration = { @@ -233,7 +226,7 @@ export default Vue.extend({ }); }, updatePasswordLessLogin() { - this.$root.api('i/2fa/password-less', { + os.api('i/2fa/password-less', { value: !!this.usePasswordLessLogin }); } diff --git a/src/client/pages/settings/security.vue b/src/client/pages/settings/security.vue new file mode 100644 index 0000000000..e56d4ae99d --- /dev/null +++ b/src/client/pages/settings/security.vue @@ -0,0 +1,102 @@ +<template> +<div> + <div class="_section"> + <X2fa/> + </div> + <div class="_section"> + <MkButton primary @click="change()" full>{{ $t('changePassword') }}</MkButton> + </div> + <div class="_section"> + <MkButton class="_vMargin" primary @click="regenerateToken" full><Fa :icon="faSyncAlt"/> {{ $t('regenerateLoginToken') }}</MkButton> + <div class="_caption _vMargin" style="padding: 0 6px;">{{ $t('regenerateLoginTokenDescription') }}</div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faLock, faSyncAlt } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import X2fa from './security.2fa.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + X2fa, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('security'), + icon: faLock + }] + }, + faLock, faSyncAlt + } + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + async change() { + const { canceled: canceled1, result: currentPassword } = await os.dialog({ + title: this.$t('currentPassword'), + input: { + type: 'password' + } + }); + if (canceled1) return; + + const { canceled: canceled2, result: newPassword } = await os.dialog({ + title: this.$t('newPassword'), + input: { + type: 'password' + } + }); + if (canceled2) return; + + const { canceled: canceled3, result: newPassword2 } = await os.dialog({ + title: this.$t('newPasswordRetype'), + input: { + type: 'password' + } + }); + if (canceled3) return; + + if (newPassword !== newPassword2) { + os.dialog({ + type: 'error', + text: this.$t('retypedNotMatch') + }); + return; + } + + os.apiWithDialog('i/change-password', { + currentPassword, + newPassword + }); + }, + + regenerateToken() { + os.dialog({ + title: this.$t('password'), + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/regenerate_token', { + password: password + }); + }); + }, + } +}); +</script> diff --git a/src/client/pages/settings/sidebar.vue b/src/client/pages/settings/sidebar.vue new file mode 100644 index 0000000000..e55899df97 --- /dev/null +++ b/src/client/pages/settings/sidebar.vue @@ -0,0 +1,110 @@ +<template> +<div class="_section"> + <div class="_card"> + <div class="_content"> + <MkTextarea v-model:value="items" tall> + <span>{{ $t('sidebar') }}</span> + <template #desc><button class="_textButton" @click="addItem">{{ $t('addItem') }}</button></template> + </MkTextarea> + </div> + <div class="_content"> + <div>{{ $t('display') }}</div> + <MkRadio v-model="sidebarDisplay" value="full">{{ $t('_sidebar.full') }}</MkRadio> + <MkRadio v-model="sidebarDisplay" value="icon">{{ $t('_sidebar.icon') }}</MkRadio> + <!-- <MkRadio v-model="sidebarDisplay" value="hide" disabled>{{ $t('_sidebar.hide') }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 --> + </div> + <div class="_footer"> + <MkButton inline @click="save()" primary><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> + <MkButton inline @click="reset()"><Fa :icon="faRedo"/> {{ $t('default') }}</MkButton> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faListUl, faSave, faRedo } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import MkRadio from '@/components/ui/radio.vue'; +import { defaultDeviceUserSettings } from '@/store'; +import * as os from '@/os'; +import { sidebarDef } from '@/sidebar'; + +export default defineComponent({ + components: { + MkButton, + MkTextarea, + MkRadio, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('sidebar'), + icon: faListUl + }] + }, + menuDef: sidebarDef, + items: '', + faSave, faRedo + } + }, + + computed: { + splited(): string[] { + return this.items.trim().split('\n').filter(x => x.trim() !== ''); + }, + + sidebarDisplay: { + get() { return this.$store.state.device.sidebarDisplay; }, + set(value) { this.$store.commit('device/set', { key: 'sidebarDisplay', value }); } + }, + }, + + created() { + this.items = this.$store.state.deviceUser.menu.join('\n'); + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + async addItem() { + const menu = Object.keys(this.menuDef).filter(k => !this.$store.state.deviceUser.menu.includes(k)); + const { canceled, result: item } = await os.dialog({ + type: null, + title: this.$t('addItem'), + select: { + items: [...menu.map(k => ({ + value: k, text: this.$t(this.menuDef[k].title) + })), ...[{ + value: '-', text: this.$t('divider') + }]] + }, + showCancelButton: true + }); + if (canceled) return; + this.items = [...this.splited, item].join('\n'); + this.save(); + }, + + save() { + this.$store.commit('deviceUser/setMenu', this.splited); + }, + + reset() { + this.$store.commit('deviceUser/setMenu', defaultDeviceUserSettings.menu); + this.items = this.$store.state.deviceUser.menu.join('\n'); + }, + }, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/src/client/pages/settings/sounds.vue b/src/client/pages/settings/sounds.vue new file mode 100644 index 0000000000..fc6b751fed --- /dev/null +++ b/src/client/pages/settings/sounds.vue @@ -0,0 +1,152 @@ +<template> +<div class="_section"> + <div class="_card"> + <div class="_title"><Fa :icon="faMusic"/> {{ $t('sounds') }}</div> + <div class="_content"> + <MkRange v-model:value="sfxVolume" :min="0" :max="1" :step="0.1"> + <Fa slot="icon" :icon="volumeIcon"/> + <span slot="title">{{ $t('volume') }}</span> + </MkRange> + </div> + <div class="_content"> + <MkSelect v-model:value="sfxNote"> + <template #label>{{ $t('_sfx.note') }}</template> + <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> + <template #text><button class="_textButton" @click="listen(sfxNote)" v-if="sfxNote"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> + </MkSelect> + <MkSelect v-model:value="sfxNoteMy"> + <template #label>{{ $t('_sfx.noteMy') }}</template> + <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> + <template #text><button class="_textButton" @click="listen(sfxNoteMy)" v-if="sfxNoteMy"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> + </MkSelect> + <MkSelect v-model:value="sfxNotification"> + <template #label>{{ $t('_sfx.notification') }}</template> + <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> + <template #text><button class="_textButton" @click="listen(sfxNotification)" v-if="sfxNotification"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> + </MkSelect> + <MkSelect v-model:value="sfxChat"> + <template #label>{{ $t('_sfx.chat') }}</template> + <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> + <template #text><button class="_textButton" @click="listen(sfxChat)" v-if="sfxChat"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> + </MkSelect> + <MkSelect v-model:value="sfxChatBg"> + <template #label>{{ $t('_sfx.chatBg') }}</template> + <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> + <template #text><button class="_textButton" @click="listen(sfxChatBg)" v-if="sfxChatBg"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> + </MkSelect> + <MkSelect v-model:value="sfxAntenna"> + <template #label>{{ $t('_sfx.antenna') }}</template> + <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> + <template #text><button class="_textButton" @click="listen(sfxAntenna)" v-if="sfxAntenna"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> + </MkSelect> + <MkSelect v-model:value="sfxChannel"> + <template #label>{{ $t('_sfx.channel') }}</template> + <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> + <template #text><button class="_textButton" @click="listen(sfxChannel)" v-if="sfxChannel"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> + </MkSelect> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faMusic, faPlay, faVolumeUp, faVolumeMute } from '@fortawesome/free-solid-svg-icons'; +import MkSelect from '@/components/ui/select.vue'; +import MkRange from '@/components/ui/range.vue'; +import * as os from '@/os'; + +const sounds = [ + null, + 'syuilo/up', + 'syuilo/down', + 'syuilo/pope1', + 'syuilo/pope2', + 'syuilo/waon', + 'syuilo/popo', + 'syuilo/triple', + 'syuilo/poi1', + 'syuilo/poi2', + 'syuilo/pirori', + 'syuilo/pirori-wet', + 'syuilo/pirori-square-wet', + 'syuilo/square-pico', + 'syuilo/reverved', + 'syuilo/ryukyu', + 'aisha/1', + 'aisha/2', + 'aisha/3', + 'noizenecio/kick_gaba', + 'noizenecio/kick_gaba2', +]; + +export default defineComponent({ + components: { + MkSelect, + MkRange, + }, + + data() { + return { + sounds, + faMusic, faPlay, faVolumeUp, faVolumeMute, + } + }, + + computed: { + sfxVolume: { + get() { return this.$store.state.device.sfxVolume; }, + set(value) { this.$store.commit('device/set', { key: 'sfxVolume', value: parseFloat(value, 10) }); } + }, + + sfxNote: { + get() { return this.$store.state.device.sfxNote; }, + set(value) { this.$store.commit('device/set', { key: 'sfxNote', value }); } + }, + + sfxNoteMy: { + get() { return this.$store.state.device.sfxNoteMy; }, + set(value) { this.$store.commit('device/set', { key: 'sfxNoteMy', value }); } + }, + + sfxNotification: { + get() { return this.$store.state.device.sfxNotification; }, + set(value) { this.$store.commit('device/set', { key: 'sfxNotification', value }); } + }, + + sfxChat: { + get() { return this.$store.state.device.sfxChat; }, + set(value) { this.$store.commit('device/set', { key: 'sfxChat', value }); } + }, + + sfxChatBg: { + get() { return this.$store.state.device.sfxChatBg; }, + set(value) { this.$store.commit('device/set', { key: 'sfxChatBg', value }); } + }, + + sfxAntenna: { + get() { return this.$store.state.device.sfxAntenna; }, + set(value) { this.$store.commit('device/set', { key: 'sfxAntenna', value }); } + }, + + sfxChannel: { + get() { return this.$store.state.device.sfxChannel; }, + set(value) { this.$store.commit('device/set', { key: 'sfxChannel', value }); } + }, + + volumeIcon: { + get() { + return this.sfxVolume === 0 ? faVolumeMute : faVolumeUp; + } + } + }, + + methods: { + listen(sound) { + const audio = new Audio(`/assets/sounds/${sound}.mp3`); + audio.volume = this.$store.state.device.sfxVolume; + audio.play(); + }, + } +}); +</script> diff --git a/src/client/pages/preferences/theme.vue b/src/client/pages/settings/theme.vue index 2461504a42..0571b6c5d1 100644 --- a/src/client/pages/preferences/theme.vue +++ b/src/client/pages/settings/theme.vue @@ -1,105 +1,115 @@ <template> -<section class="rfqxtzch _card"> - <div class="_title"><fa :icon="faPalette"/> {{ $t('theme') }}</div> - <div class="_content"> - <div class="darkMode" :class="{ disabled: syncDeviceDarkMode }"> - <div class="toggleWrapper"> - <input type="checkbox" class="dn" id="dn" v-model="darkMode" :disabled="syncDeviceDarkMode"/> - <label for="dn" class="toggle"> - <span class="before">{{ $t('light') }}</span> - <span class="after">{{ $t('dark') }}</span> - <span class="toggle__handler"> - <span class="crater crater--1"></span> - <span class="crater crater--2"></span> - <span class="crater crater--3"></span> - </span> - <span class="star star--1"></span> - <span class="star star--2"></span> - <span class="star star--3"></span> - <span class="star star--4"></span> - <span class="star star--5"></span> - <span class="star star--6"></span> - </label> +<div class="_section"> + <div class="rfqxtzch _card _vMargin"> + <div class="_content"> + <div class="darkMode" :class="{ disabled: syncDeviceDarkMode }"> + <div class="toggleWrapper"> + <input type="checkbox" class="dn" id="dn" v-model="darkMode" :disabled="syncDeviceDarkMode"/> + <label for="dn" class="toggle"> + <span class="before">{{ $t('light') }}</span> + <span class="after">{{ $t('dark') }}</span> + <span class="toggle__handler"> + <span class="crater crater--1"></span> + <span class="crater crater--2"></span> + <span class="crater crater--3"></span> + </span> + <span class="star star--1"></span> + <span class="star star--2"></span> + <span class="star star--3"></span> + <span class="star star--4"></span> + <span class="star star--5"></span> + <span class="star star--6"></span> + </label> + </div> </div> + <MkSwitch v-model:value="syncDeviceDarkMode">{{ $t('syncDeviceDarkMode') }}</MkSwitch> + </div> + <div class="_content"> + <MkSelect v-model:value="lightTheme"> + <template #label>{{ $t('themeForLightMode') }}</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> + </MkSelect> + <MkSelect v-model:value="darkTheme"> + <template #label>{{ $t('themeForDarkMode') }}</template> + <optgroup :label="$t('darkThemes')"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$t('lightThemes')"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </MkSelect> + <a href="https://assets.msky.cafe/theme/list" rel="noopener" target="_blank" class="_link">{{ $t('_theme.explore') }}</a>・<router-link to="/theme-editor" class="_link">{{ $t('_theme.make') }}</router-link> + </div> + <div class="_content"> + <MkButton primary v-if="wallpaper == null" @click="setWallpaper">{{ $t('setWallpaper') }}</MkButton> + <MkButton primary v-else @click="wallpaper = null">{{ $t('removeWallpaper') }}</MkButton> </div> - <mk-switch v-model="syncDeviceDarkMode">{{ $t('syncDeviceDarkMode') }}</mk-switch> - </div> - <div class="_content"> - <mk-select v-model="lightTheme"> - <template #label>{{ $t('themeForLightMode') }}</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> - <mk-select v-model="darkTheme"> - <template #label>{{ $t('themeForDarkMode') }}</template> - <optgroup :label="$t('darkThemes')"> - <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="$t('lightThemes')"> - <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - </mk-select> - <a href="https://assets.msky.cafe/theme/list" rel="noopener" target="_blank" class="_link">{{ $t('_theme.explore') }}</a>・<router-link to="/theme-editor" class="_link">{{ $t('_theme.make') }}</router-link> - </div> - <div class="_content"> - <mk-button primary v-if="wallpaper == null" @click="setWallpaper">{{ $t('setWallpaper') }}</mk-button> - <mk-button primary v-else @click="wallpaper = null">{{ $t('removeWallpaper') }}</mk-button> </div> - <div class="_content"> - <details> - <summary><fa :icon="faDownload"/> {{ $t('_theme.install') }}</summary> - <mk-textarea v-model="installThemeCode"> + <div class="_card _vMargin"> + <div class="_title"><Fa :icon="faDownload"/> {{ $t('_theme.install') }}</div> + <div class="_content"> + <MkTextarea v-model:value="installThemeCode"> <span>{{ $t('_theme.code') }}</span> - </mk-textarea> - <mk-button @click="() => install(installThemeCode)" :disabled="installThemeCode == null" primary inline><fa :icon="faCheck"/> {{ $t('install') }}</mk-button> - <mk-button @click="() => preview(installThemeCode)" :disabled="installThemeCode == null" inline><fa :icon="faEye"/> {{ $t('preview') }}</mk-button> - </details> + </MkTextarea> + <MkButton @click="() => install(installThemeCode)" :disabled="installThemeCode == null" primary inline><Fa :icon="faCheck"/> {{ $t('install') }}</MkButton> + <MkButton @click="() => preview(installThemeCode)" :disabled="installThemeCode == null" inline><Fa :icon="faEye"/> {{ $t('preview') }}</MkButton> + </div> </div> - <div class="_content"> - <details> - <summary><fa :icon="faFolderOpen"/> {{ $t('_theme.manage') }}</summary> - <mk-select v-model="selectedThemeId"> + <div class="_card _vMargin"> + <div class="_title"><Fa :icon="faFolderOpen"/> {{ $t('_theme.manage') }}</div> + <div class="_content"> + <MkSelect v-model:value="selectedThemeId"> <option v-for="x in installedThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </mk-select> + </MkSelect> <template v-if="selectedTheme"> - <mk-textarea readonly tall :value="selectedThemeCode"> + <MkTextarea readonly tall :value="selectedThemeCode"> <span>{{ $t('_theme.code') }}</span> <template #desc><button @click="copyThemeCode()" class="_textButton">{{ $t('copy') }}</button></template> - </mk-textarea> - <mk-button @click="uninstall()" v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</mk-button> + </MkTextarea> + <MkButton @click="uninstall()" v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><Fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</MkButton> </template> - </details> + </div> </div> -</section> +</div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye } from '@fortawesome/free-solid-svg-icons'; import * as JSON5 from 'json5'; -import MkButton from '../../components/ui/button.vue'; -import MkSelect from '../../components/ui/select.vue'; -import MkSwitch from '../../components/ui/switch.vue'; -import MkTextarea from '../../components/ui/textarea.vue'; -import { Theme, builtinThemes, applyTheme, validateTheme } from '../../scripts/theme'; -import { selectFile } from '../../scripts/select-file'; -import { isDeviceDarkmode } from '../../scripts/is-device-darkmode'; -import copyToClipboard from '../../scripts/copy-to-clipboard'; +import MkButton from '@/components/ui/button.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import { Theme, builtinThemes, applyTheme, validateTheme } from '@/scripts/theme'; +import { selectFile } from '@/scripts/select-file'; +import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkButton, MkSelect, MkSwitch, MkTextarea, }, + + emits: ['info'], data() { return { + INFO: { + header: [{ + title: this.$t('theme'), + icon: faPalette + }] + }, builtinThemes, installThemeCode: null, selectedThemeId: null, @@ -185,19 +195,20 @@ export default Vue.extend({ } }, + mounted() { + this.$emit('info', this.INFO); + }, + methods: { setWallpaper(e) { - selectFile(this, e.currentTarget || e.target, null, false).then(file => { + selectFile(e.currentTarget || e.target, null, false).then(file => { this.wallpaper = file.url; }); }, copyThemeCode() { copyToClipboard(this.selectedThemeCode); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); }, parseThemeCode(code) { @@ -206,21 +217,21 @@ export default Vue.extend({ try { theme = JSON5.parse(code); } catch (e) { - this.$root.dialog({ + os.dialog({ type: 'error', text: this.$t('_theme.invalid') }); return false; } if (!validateTheme(theme)) { - this.$root.dialog({ + os.dialog({ type: 'error', text: this.$t('_theme.invalid') }); return false; } if (this.$store.state.device.themes.some(t => t.id === theme.id)) { - this.$root.dialog({ + os.dialog({ type: 'info', text: this.$t('_theme.alreadyInstalled') }); @@ -242,7 +253,7 @@ export default Vue.extend({ this.$store.commit('device/set', { key: 'themes', value: themes }); - this.$root.dialog({ + os.dialog({ type: 'success', text: this.$t('_theme.installed', { name: theme.name }) }); @@ -254,10 +265,7 @@ export default Vue.extend({ this.$store.commit('device/set', { key: 'themes', value: themes }); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); + os.success(); }, } }); diff --git a/src/client/pages/settings/word-mute.vue b/src/client/pages/settings/word-mute.vue new file mode 100644 index 0000000000..a517536a1c --- /dev/null +++ b/src/client/pages/settings/word-mute.vue @@ -0,0 +1,101 @@ +<template> +<div class="_section"> + <div class="_card"> + <MkTab v-model:value="tab" :items="[{ label: $t('_wordMute.soft'), value: 'soft' }, { label: $t('_wordMute.hard'), value: 'hard' }]"/> + <div class="_content"> + <div v-show="tab === 'soft'"> + <MkInfo>{{ $t('_wordMute.softDescription') }}</MkInfo> + <MkTextarea v-model:value="softMutedWords"> + <span>{{ $t('_wordMute.muteWords') }}</span> + <template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template> + </MkTextarea> + </div> + <div v-show="tab === 'hard'"> + <MkInfo>{{ $t('_wordMute.hardDescription') }}</MkInfo> + <MkTextarea v-model:value="hardMutedWords" style="margin-bottom: 16px;"> + <span>{{ $t('_wordMute.muteWords') }}</span> + <template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template> + </MkTextarea> + <div v-if="hardWordMutedNotesCount != null" class="_caption">{{ $t('_wordMute.mutedNotes') }}: {{ hardWordMutedNotesCount | number }}</div> + </div> + </div> + <div class="_footer"> + <MkButton @click="save()" primary inline :disabled="!changed"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faCommentSlash, faSave } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import MkTab from '@/components/tab.vue'; +import MkInfo from '@/components/ui/info.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkTextarea, + MkTab, + MkInfo, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('wordMute'), + icon: faCommentSlash + }] + }, + tab: 'soft', + softMutedWords: '', + hardMutedWords: '', + hardWordMutedNotesCount: null, + changed: false, + faSave, + } + }, + + watch: { + softMutedWords: { + handler() { + this.changed = true; + }, + deep: true + }, + hardMutedWords: { + handler() { + this.changed = true; + }, + deep: true + }, + }, + + async created() { + this.softMutedWords = this.$store.state.settings.mutedWords.map(x => x.join(' ')).join('\n'); + this.hardMutedWords = this.$store.state.i.mutedWords.map(x => x.join(' ')).join('\n'); + + this.hardWordMutedNotesCount = (await os.api('i/get-word-muted-notes-count', {})).count; + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + async save() { + this.$store.dispatch('settings/set', { key: 'mutedWords', value: this.softMutedWords.trim().split('\n').map(x => x.trim().split(' ')) }); + await os.api('i/update', { + mutedWords: this.hardMutedWords.trim().split('\n').map(x => x.trim().split(' ')), + }); + this.changed = false; + }, + } +}); +</script> diff --git a/src/client/pages/share.vue b/src/client/pages/share.vue index 153de76801..dd1e82dedb 100644 --- a/src/client/pages/share.vue +++ b/src/client/pages/share.vue @@ -1,14 +1,10 @@ <template> <div class=""> - <portal to="icon"><fa :icon="faShareAlt"/></portal> - <portal to="title">{{ $t('share') }}</portal> - - <section class="_card"> + <section class="_section"> <div class="_title" v-if="title">{{ title }}</div> <div class="_content"> - <div>{{ text }}</div> - <mk-button @click="post()" v-if="!posted">{{ $t('post') }}</mk-button> - <mk-button primary @click="close()" v-else>{{ $t('close') }}</mk-button> + <XPostForm v-if="!posted" fixed :instant="true" :initial-text="initialText" @posted="posted = true" class="_panel"/> + <MkButton v-else primary @click="close()">{{ $t('close') }}</MkButton> </div> <div class="_footer" v-if="url">{{ url }}</div> </section> @@ -16,27 +12,30 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faShareAlt } from '@fortawesome/free-solid-svg-icons'; -import PostFormDialog from '../components/post-form-dialog.vue'; -import MkButton from '../components/ui/button.vue'; - -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('share') as string - }; - }, +import MkButton from '@/components/ui/button.vue'; +import XPostForm from '@/components/post-form.vue'; +import * as os from '@/os'; +export default defineComponent({ components: { - MkButton + XPostForm, + MkButton, }, data() { return { + INFO: { + header: [{ + title: this.$t('share'), + icon: faShareAlt + }], + }, title: null, text: null, url: null, + initialText: null, posted: false, faShareAlt @@ -48,29 +47,15 @@ export default Vue.extend({ this.title = urlParams.get('title'); this.text = urlParams.get('text'); this.url = urlParams.get('url'); - }, - - mounted() { - this.post(); + + let text = ''; + if (this.title) text += `【${this.title}】\n`; + if (this.text) text += `${this.text}\n`; + if (this.url) text += `${this.url}`; + this.initialText = text.trim(); }, methods: { - post() { - let text = ''; - if (this.title) text += `【${this.title}】\n`; - if (this.text) text += `${this.text}\n`; - if (this.url) text += `${this.url}`; - this.$root.new(PostFormDialog, { - instant: true, - initialText: text.trim() - }).$once('posted', () => { - this.posted = true; - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }); - }, close() { window.close() } diff --git a/src/client/pages/tag.vue b/src/client/pages/tag.vue index 81a96960f7..cea74d1e17 100644 --- a/src/client/pages/tag.vue +++ b/src/client/pages/tag.vue @@ -1,31 +1,28 @@ <template> -<div> - <portal to="icon"><fa :icon="faHashtag"/></portal> - <portal to="title">{{ $route.params.tag }}</portal> - - <x-notes ref="notes" :pagination="pagination" @before="before" @after="after"/> +<div class="_section"> + <XNotes ref="notes" class="_content" :pagination="pagination" @before="before" @after="after"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faHashtag } 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.$route.params.tag - }; - }, +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +export default defineComponent({ components: { XNotes }, data() { return { + INFO: { + header: [{ + title: this.$route.params.tag, + icon: faHashtag + }], + }, pagination: { endpoint: 'notes/search-by-tag', limit: 10, diff --git a/src/client/pages/test.vue b/src/client/pages/test.vue new file mode 100644 index 0000000000..02b4d1614d --- /dev/null +++ b/src/client/pages/test.vue @@ -0,0 +1,232 @@ +<template> +<div class="_section"> + <div class="_content"> + <div class="_card _vMargin"> + <div class="_title">Dialog</div> + <div class="_content"> + <MkInput v-model:value="dialogTitle"> + <span>Title</span> + </MkInput> + <MkInput v-model:value="dialogBody"> + <span>Body</span> + </MkInput> + <MkSwitch v-model:value="dialogCancel"> + <span>With cancel button</span> + </MkSwitch> + <MkSwitch v-model:value="dialogCancelByBgClick"> + <span>Can cancel by modal bg click</span> + </MkSwitch> + <MkSwitch v-model:value="dialogInput"> + <span>With input field</span> + </MkSwitch> + <MkButton @click="showDialog()">Show</MkButton> + </div> + <div class="_content"> + <code>Result: {{ dialogResult }}</code> + </div> + </div> + + <div class="_card _vMargin"> + <div class="_title">Form</div> + <div class="_content"> + <MkInput v-model:value="formTitle"> + <span>Title</span> + </MkInput> + <MkTextarea v-model:value="formForm"> + <span>Form</span> + </MkTextarea> + <MkButton @click="form()">Show</MkButton> + </div> + <div class="_content"> + <code>Result: {{ formResult }}</code> + </div> + </div> + + <div class="_card _vMargin"> + <div class="_title">MFM</div> + <div class="_content"> + <MkTextarea v-model:value="mfm"> + <span>MFM</span> + </MkTextarea> + </div> + <div class="_content"> + <Mfm :text="mfm"/> + </div> + </div> + + <div class="_card _vMargin"> + <div class="_title">selectDriveFile</div> + <div class="_content"> + <MkSwitch v-model:value="selectDriveFileMultiple"> + <span>Multiple</span> + </MkSwitch> + <MkButton @click="selectDriveFile()">selectDriveFile</MkButton> + </div> + <div class="_content"> + <code>Result: {{ JSON.stringify(selectDriveFileResult) }}</code> + </div> + </div> + + <div class="_card _vMargin"> + <div class="_title">selectDriveFolder</div> + <div class="_content"> + <MkSwitch v-model:value="selectDriveFolderMultiple"> + <span>Multiple</span> + </MkSwitch> + <MkButton @click="selectDriveFolder()">selectDriveFolder</MkButton> + </div> + <div class="_content"> + <code>Result: {{ JSON.stringify(selectDriveFolderResult) }}</code> + </div> + </div> + + <div class="_card _vMargin"> + <div class="_title">selectUser</div> + <div class="_content"> + <MkButton @click="selectUser()">selectUser</MkButton> + </div> + <div class="_content"> + <code>Result: {{ user }}</code> + </div> + </div> + + <div class="_card _vMargin"> + <div class="_title">Notification</div> + <div class="_content"> + <MkInput v-model:value="notificationIconUrl"> + <span>Icon URL</span> + </MkInput> + <MkInput v-model:value="notificationHeader"> + <span>Header</span> + </MkInput> + <MkTextarea v-model:value="notificationBody"> + <span>Body</span> + </MkTextarea> + <MkButton @click="createNotification()">createNotification</MkButton> + </div> + </div> + + <div class="_card _vMargin"> + <div class="_title">Messaging window</div> + <div class="_content"> + <MkButton @click="messagingWindowOpen()">open</MkButton> + </div> + </div> + + <MkButton @click="resetTutorial()">Reset tutorial</MkButton> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSwitch, + MkTextarea, + }, + + data() { + return { + INFO: { + header: [{ + title: 'TEST', + icon: faExclamationTriangle + }] + }, + dialogTitle: 'Hello', + dialogBody: 'World!', + dialogCancel: false, + dialogCancelByBgClick: true, + dialogInput: false, + dialogResult: null, + formTitle: null, + formForm: JSON.stringify({ + foo: { + type: 'boolean', + default: true, + label: 'This is a boolean property' + }, + bar: { + type: 'number', + default: 300, + label: 'This is a number property' + }, + baz: { + type: 'string', + default: 'Misskey makes you happy.', + label: 'This is a string property' + }, + }, null, '\t'), + formResult: null, + mfm: '', + selectDriveFileMultiple: false, + selectDriveFolderMultiple: false, + selectDriveFileResult: null, + selectDriveFolderResult: null, + user: null, + notificationIconUrl: null, + notificationHeader: '', + notificationBody: '', + } + }, + + methods: { + async showDialog() { + this.dialogResult = null; + this.dialogResult = await os.dialog({ + title: this.dialogTitle, + text: this.dialogBody, + showCancelButton: this.dialogCancel, + cancelableByBgClick: this.dialogCancelByBgClick, + input: this.dialogInput ? {} : null + }); + }, + + async form() { + this.formResult = null; + this.formResult = await os.form(this.formTitle, JSON.parse(this.formForm)); + }, + + async selectDriveFile() { + this.selectDriveFileResult = null; + this.selectDriveFileResult = await os.selectDriveFile(this.selectDriveFileMultiple); + }, + + async selectDriveFolder() { + this.selectDriveFolderResult = null; + this.selectDriveFolderResult = await os.selectDriveFolder(this.selectDriveFolderMultiple); + }, + + async selectUser() { + this.user = null; + this.user = await os.selectUser(); + }, + + async createNotification() { + os.api('notifications/create', { + header: this.notificationHeader, + body: this.notificationBody, + icon: this.notificationIconUrl, + }); + }, + + messagingWindowOpen() { + os.pageWindow('/my/messaging', defineAsyncComponent(() => import('@/pages/messaging/index.vue'))); + }, + + resetTutorial() { + this.$store.dispatch('settings/set', { key: 'tutorial', value: 0 }); + }, + } +}); +</script> diff --git a/src/client/pages/theme-editor.vue b/src/client/pages/theme-editor.vue index 2ad95c065e..5b59d025d9 100644 --- a/src/client/pages/theme-editor.vue +++ b/src/client/pages/theme-editor.vue @@ -1,21 +1,31 @@ <template> <div class="t9makv94"> - <portal to="icon"><fa :icon="faPalette"/></portal> - <portal to="title">{{ $t('themeEditor') }}</portal> - - <section class="_card"> + <section class="_section"> <div class="_content"> - <mk-input v-model="name" required><span>{{ $t('name') }}</span></mk-input> - <mk-input v-model="author" required><span>{{ $t('author') }}</span></mk-input> - <mk-textarea v-model="description"><span>{{ $t('description') }}</span></mk-textarea> - <div class="_inputs"> - <div v-text="$t('_theme.base')" /> - <mk-radio v-model="baseTheme" value="light">{{ $t('light') }}</mk-radio> - <mk-radio v-model="baseTheme" value="dark">{{ $t('dark') }}</mk-radio> + <details> + <summary>{{ $t('import') }}</summary> + <MkTextarea v-model:value="themeToImport"> + {{ $t('_theme.importInfo') }} + </MkTextarea> + <MkButton :disabled="!themeToImport.trim()" @click="importTheme">{{ $t('import') }}</MkButton> + </details> + </div> + </section> + <section class="_section"> + <div class="_content _card _vMargin"> + <div class="_content"> + <MkInput v-model:value="name" required><span>{{ $t('name') }}</span></MkInput> + <MkInput v-model:value="author" required><span>{{ $t('author') }}</span></MkInput> + <MkTextarea v-model:value="description"><span>{{ $t('description') }}</span></MkTextarea> + <div class="_inputs"> + <div v-text="$t('_theme.base')" /> + <MkRadio v-model="baseTheme" value="light">{{ $t('light') }}</MkRadio> + <MkRadio v-model="baseTheme" value="dark">{{ $t('dark') }}</MkRadio> + </div> </div> </div> - <div class="_content"> - <div class="list-view"> + <div class="_content _card _vMargin"> + <div class="list-view _content"> <div class="item" v-for="([ k, v ], i) in theme" :key="k"> <div class="_inputs"> <div> @@ -24,73 +34,74 @@ </div> <div> <div class="type" @click="chooseType($event, i)"> - {{ getTypeOf(v) }} <fa :icon="faChevronDown"/> + {{ getTypeOf(v) }} <Fa :icon="faChevronDown"/> </div> <!-- default --> <div v-if="v === null" v-text="baseProps[k]" class="default-value" /> <!-- color --> <div v-else-if="typeof v === 'string'" class="color"> <input type="color" :value="v" @input="colorChanged($event.target.value, i)"/> - <mk-input class="select" :value="v" @input="colorChanged($event, i)"/> + <MkInput class="select" :value="v" @update:value="colorChanged($event, i)"/> </div> <!-- ref const --> - <mk-input v-else-if="v.type === 'refConst'" v-model="v.key"> + <MkInput v-else-if="v.type === 'refConst'" v-model:value="v.key"> <template #prefix>$</template> <span>{{ $t('name') }}</span> - </mk-input> + </MkInput> <!-- ref props --> - <mk-select class="select" v-else-if="v.type === 'refProp'" v-model="v.key"> + <MkSelect class="select" v-else-if="v.type === 'refProp'" v-model:value="v.key"> <option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option> - </mk-select> + </MkSelect> <!-- func --> <template v-else-if="v.type === 'func'"> - <mk-select class="select" v-model="v.name"> + <MkSelect class="select" v-model:value="v.name"> <template #label>{{ $t('_theme.funcKind') }}</template> <option v-for="n in ['alpha', 'darken', 'lighten']" :value="n" :key="n">{{ $t('_theme.' + n) }}</option> - </mk-select> - <mk-input type="number" v-model="v.arg"><span>{{ $t('_theme.argument') }}</span></mk-input> - <mk-select class="select" v-model="v.value"> + </MkSelect> + <MkInput type="number" v-model:value="v.arg"><span>{{ $t('_theme.argument') }}</span></MkInput> + <MkSelect class="select" v-model:value="v.value"> <template #label>{{ $t('_theme.basedProp') }}</template> <option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option> - </mk-select> + </MkSelect> </template> + <!-- CSS --> + <MkInput v-else-if="v.type === 'css'" v-model:value="v.value"> + <span>CSS</span> + </MkInput> </div> </div> </div> - <mk-button primary @click="addConst">{{ $t('_theme.addConstant') }}</mk-button> + <MkButton primary @click="addConst">{{ $t('_theme.addConstant') }}</MkButton> </div> </div> + </section> + <section class="_section"> <div class="_content"> - <mk-textarea v-model="themeToImport"> - {{ $t('_theme.importInfo') }} - </mk-textarea> - <mk-button :disabled="!themeToImport.trim()" @click="importTheme">{{ $t('import') }}</mk-button> - </div> - <div class="_footer"> - <mk-button inline @click="preview">{{ $t('preview') }}</mk-button> - <mk-button inline primary :disabled="!name || !author" @click="save">{{ $t('save') }}</mk-button> + <MkButton inline @click="preview">{{ $t('preview') }}</MkButton> + <MkButton inline primary :disabled="!name || !author" @click="save">{{ $t('save') }}</MkButton> </div> </section> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faPalette, faChevronDown, faKeyboard } from '@fortawesome/free-solid-svg-icons'; import * as JSON5 from 'json5'; -import MkRadio from '../components/ui/radio.vue'; -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 MkRadio from '@/components/ui/radio.vue'; +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 { convertToMisskeyTheme, ThemeValue, convertToViewModel, ThemeViewModel } from '../scripts/theme-editor'; -import { Theme, applyTheme, lightTheme, darkTheme, themeProps, validateTheme } from '../scripts/theme'; +import { convertToMisskeyTheme, ThemeValue, convertToViewModel, ThemeViewModel } from '@/scripts/theme-editor'; +import { Theme, applyTheme, lightTheme, darkTheme, themeProps, validateTheme } from '@/scripts/theme'; import { toUnicode } from 'punycode'; -import { host } from '../config'; +import { host } from '@/config'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { MkRadio, MkButton, @@ -98,14 +109,15 @@ export default Vue.extend({ MkTextarea, MkSelect }, - metaInfo() { - return { - title: this.$t('themeEditor') + (this.changed ? '*' : '') - }; - }, data() { return { + INFO: { + header: [{ + title: this.$t('themeEditor'), + icon: faPalette, + }], + }, theme: [] as ThemeViewModel, name: '', description: '', @@ -113,8 +125,8 @@ export default Vue.extend({ author: `@${this.$store.state.i.username}@${toUnicode(host)}`, themeToImport: '', changed: false, - faPalette, faChevronDown, faKeyboard, lightTheme, darkTheme, themeProps, + faPalette, faChevronDown, faKeyboard, } }, @@ -124,7 +136,7 @@ export default Vue.extend({ }, }, - beforeDestroy() { + beforeUnmount() { window.removeEventListener('beforeunload', this.beforeunload); }, @@ -156,7 +168,7 @@ export default Vue.extend({ }, async confirm(): Promise<boolean> { - const { canceled } = await this.$root.dialog({ + const { canceled } = await os.dialog({ type: 'warning', text: this.$t('leaveConfirm'), showCancelButton: true @@ -173,7 +185,7 @@ export default Vue.extend({ }, async del(i: number) { - const { canceled } = await this.$root.dialog({ + const { canceled } = await os.dialog({ type: 'warning', showCancelButton: true, text: this.$t('_theme.deleteConstantConfirm', { const: this.theme[i][0] }), @@ -183,7 +195,7 @@ export default Vue.extend({ }, async addConst() { - const { canceled, result } = await this.$root.dialog({ + const { canceled, result } = await os.dialog({ title: this.$t('_theme.inputConstantName'), input: true }); @@ -197,7 +209,7 @@ export default Vue.extend({ this.$store.commit('device/set', { key: 'themes', value: themes }); - this.$root.dialog({ + os.dialog({ type: 'success', text: this.$t('_theme.installed', { name: theme.name }) }); @@ -209,7 +221,7 @@ export default Vue.extend({ try { applyTheme(theme, false); } catch (e) { - this.$root.dialog({ + os.dialog({ type: 'error', text: e.message }); @@ -230,7 +242,7 @@ export default Vue.extend({ this.theme = convertToViewModel(theme); this.themeToImport = ''; } catch (e) { - this.$root.dialog({ + os.dialog({ type: 'error', text: e.message }); @@ -238,9 +250,9 @@ export default Vue.extend({ }, colorChanged(color: string, i: number) { - Vue.set(this.theme, i, [this.theme[i][0], color]); + this.theme[i] = [this.theme[i][0], color]; }, - + getTypeOf(v: ThemeValue) { return v === null ? this.$t('_theme.defaultValue') @@ -251,36 +263,38 @@ export default Vue.extend({ async chooseType(e: MouseEvent, i: number) { const newValue = await this.showTypeMenu(e); - Vue.set(this.theme, i, [ this.theme[i][0], newValue ]); + this.theme[i] = [ this.theme[i][0], newValue ]; }, showTypeMenu(e: MouseEvent) { return new Promise<ThemeValue>((resolve) => { - this.$root.menu({ - items: [{ - text: this.$t('_theme.defaultValue'), - action: () => resolve(null), - }, { - text: this.$t('_theme.color'), - action: () => resolve('#000000'), - }, { - text: this.$t('_theme.func'), - action: () => resolve({ - type: 'func', name: 'alpha', arg: 1, value: 'accent' - }), - }, { - text: this.$t('_theme.refProp'), - action: () => resolve({ - type: 'refProp', key: 'accent', - }), - }, { - text: this.$t('_theme.refConst'), - action: () => resolve({ - type: 'refConst', key: '', - }), - },], - source: e.currentTarget || e.target, - }); + os.modalMenu([{ + text: this.$t('_theme.defaultValue'), + action: () => resolve(null), + }, { + text: this.$t('_theme.color'), + action: () => resolve('#000000'), + }, { + text: this.$t('_theme.func'), + action: () => resolve({ + type: 'func', name: 'alpha', arg: 1, value: 'accent' + }), + }, { + text: this.$t('_theme.refProp'), + action: () => resolve({ + type: 'refProp', key: 'accent', + }), + }, { + text: this.$t('_theme.refConst'), + action: () => resolve({ + type: 'refConst', key: '', + }), + }, { + text: 'CSS', + action: () => resolve({ + type: 'css', value: '', + }), + }], e.currentTarget || e.target); }); } } @@ -289,20 +303,15 @@ export default Vue.extend({ <style lang="scss" scoped> .t9makv94 { - > ._card { + > ._section { > ._content { > .list-view { - height: 480px; - overflow: auto; - border: 1px solid var(--divider); - > .item { min-height: 48px; - padding: 0 16px; word-break: break-all; &:not(:last-child) { - padding-bottom: 8px; + margin-bottom: 8px; } .select { @@ -332,10 +341,6 @@ export default Vue.extend({ } } } - - > ._button { - margin: 16px; - } } } } diff --git a/src/client/pages/index.home.tutorial.vue b/src/client/pages/timeline.tutorial.vue index dc255bf6a3..506e97e1b5 100644 --- a/src/client/pages/index.home.tutorial.vue +++ b/src/client/pages/timeline.tutorial.vue @@ -1,6 +1,6 @@ <template> <div class="_card tbkwesmv"> - <div class="_title"><fa :icon="faInfoCircle"/> {{ $t('_tutorial.title') }}</div> + <div class="_title"><Fa :icon="faInfoCircle"/> {{ $t('_tutorial.title') }}</div> <div class="_content" v-if="tutorial === 0"> <div>{{ $t('_tutorial.step1_1') }}</div> <div>{{ $t('_tutorial.step1_2') }}</div> @@ -9,7 +9,7 @@ <div class="_content" v-else-if="tutorial === 1"> <div>{{ $t('_tutorial.step2_1') }}</div> <div>{{ $t('_tutorial.step2_2') }}</div> - <router-link class="_link" to="/my/settings">{{ $t('editProfile') }}</router-link> + <router-link class="_link" to="/settings/profile">{{ $t('editProfile') }}</router-link> </div> <div class="_content" v-else-if="tutorial === 2"> <div>{{ $t('_tutorial.step3_1') }}</div> @@ -23,10 +23,14 @@ </div> <div class="_content" v-else-if="tutorial === 4"> <div>{{ $t('_tutorial.step5_1') }}</div> - <i18n path="_tutorial.step5_2" tag="div"> - <router-link class="_link" place="featured" to="/featured">{{ $t('featured') }}</router-link> - <router-link class="_link" place="explore" to="/explore">{{ $t('explore') }}</router-link> - </i18n> + <i18n-t keypath="_tutorial.step5_2" tag="div"> + <template #featured> + <router-link class="_link" to="/featured">{{ $t('featured') }}</router-link> + </template> + <template #explore> + <router-link class="_link" to="/explore">{{ $t('explore') }}</router-link> + </template> + </i18n-t> <div>{{ $t('_tutorial.step5_3') }}</div> <small>{{ $t('_tutorial.step5_4') }}</small> </div> @@ -37,34 +41,36 @@ </div> <div class="_content" v-else-if="tutorial === 6"> <div>{{ $t('_tutorial.step7_1') }}</div> - <i18n path="_tutorial.step7_2" tag="div"> - <router-link class="_link" place="help" to="/docs">{{ $t('help') }}</router-link> - </i18n> + <i18n-t keypath="_tutorial.step7_2" tag="div"> + <template #help> + <router-link class="_link" to="/docs">{{ $t('help') }}</router-link> + </template> + </i18n-t> <div>{{ $t('_tutorial.step7_3') }}</div> </div> <div class="_footer navigation"> <div class="step"> <button class="arrow _button" @click="tutorial--" :disabled="tutorial === 0"> - <fa :icon="faChevronLeft"/> + <Fa :icon="faChevronLeft"/> </button> <span>{{ tutorial + 1 }} / 7</span> <button class="arrow _button" @click="tutorial++" :disabled="tutorial === 6"> - <fa :icon="faChevronRight"/> + <Fa :icon="faChevronRight"/> </button> </div> - <mk-button class="ok" @click="tutorial = -1" primary v-if="tutorial === 6"><fa :icon="faCheck"/> {{ $t('gotIt') }}</mk-button> - <mk-button class="ok" @click="tutorial++" primary v-else><fa :icon="faCheck"/> {{ $t('next') }}</mk-button> + <MkButton class="ok" @click="tutorial = -1" primary v-if="tutorial === 6"><Fa :icon="faCheck"/> {{ $t('gotIt') }}</MkButton> + <MkButton class="ok" @click="tutorial++" primary v-else><Fa :icon="faCheck"/> {{ $t('next') }}</MkButton> </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faInfoCircle, faChevronLeft, faChevronRight, faCheck } from '@fortawesome/free-solid-svg-icons' -import MkButton from '../components/ui/button.vue'; +import MkButton from '@/components/ui/button.vue'; -export default Vue.extend({ +export default defineComponent({ components: { MkButton, }, diff --git a/src/client/pages/index.home.vue b/src/client/pages/timeline.vue index d3f60ea910..a15d57e37e 100644 --- a/src/client/pages/index.home.vue +++ b/src/client/pages/timeline.vue @@ -1,58 +1,45 @@ <template> <div class="mk-home" v-hotkey.global="keymap"> - <portal to="header" v-if="showTitle"> - <button @click="choose" class="_button _kjvfvyph_"> - <i><fa v-if="$store.state.i.hasUnreadAntenna || $store.state.i.hasUnreadChannel" :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"/> - <fa v-if="src === 'channel'" :icon="faSatelliteDish"/> - <span style="margin-left: 8px;">{{ src === 'list' ? list.name : src === 'antenna' ? antenna.name : src === 'channel' ? channel.name : $t('_timelines.' + src) }}</span> - <fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/> - </button> - </portal> - <div class="new" v-if="queue > 0" :style="{ width: width + 'px' }"><button class="_buttonPrimary" @click="top()">{{ $t('newNoteRecived') }}</button></div> - <x-tutorial class="tutorial" v-if="$store.state.settings.tutorial != -1"/> - - <x-post-form class="post-form _panel" fixed v-if="$store.state.device.showFixedPostForm"/> - <x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src === 'channel' ? `channel:${channel.id}` : src" :src="src" :list="list ? list.id : null" :antenna="antenna ? antenna.id : null" :channel="channel ? channel.id : null" :sound="true" @before="before()" @after="after()" @queue="queueUpdated"/> + <div class="_section"> + <XTutorial v-if="$store.state.settings.tutorial != -1" class="tutorial _content _vMargin"/> + <XPostForm v-if="$store.state.device.showFixedPostForm" class="post-form _panel _content _vMargin" fixed/> + <XTimeline ref="tl" + class="_content _vMargin" + :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src === 'channel' ? `channel:${channel.id}` : src" + :src="src" + :list="list ? list.id : null" + :antenna="antenna ? antenna.id : null" + :channel="channel ? channel.id : null" + :sound="true" + @before="before()" + @after="after()" + @queue="queueUpdated" + /> + </div> </div> </template> <script lang="ts"> -import Vue from 'vue'; -import { faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faListUl, faSatellite, faSatelliteDish, faCircle } from '@fortawesome/free-solid-svg-icons'; +import { defineComponent, defineAsyncComponent, computed } from 'vue'; +import { faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faListUl, faSatellite, faSatelliteDish, faCircle, faEllipsisH, faPencilAlt } 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'; -import XPostForm from '../components/post-form.vue'; -import { scroll } from '../scripts/scroll'; +import Progress from '@/scripts/loading'; +import XTimeline from '@/components/timeline.vue'; +import XPostForm from '@/components/post-form.vue'; +import { scroll } from '@/scripts/scroll'; +import * as os from '@/os'; -export default Vue.extend({ - metaInfo() { - return { - title: this.$t('timeline') as string - }; - }, +export default defineComponent({ + name: 'timeline', components: { XTimeline, - XTutorial: () => import('./index.home.tutorial.vue').then(m => m.default), + XTutorial: defineAsyncComponent(() => import('./timeline.tutorial.vue')), XPostForm, }, - props: { - showTitle: { - type: Boolean, - required: true - } - }, - data() { return { src: 'home', @@ -62,6 +49,47 @@ export default Vue.extend({ menuOpened: false, queue: 0, width: 0, + INFO: { + header: [{ + id: 'home', + title: null, + tooltip: this.$t('_timelines.home'), + icon: faHome, + onClick: () => { this.src = 'home'; this.saveSrc(); }, + selected: computed(() => this.src === 'home') + }, { + id: 'local', + title: null, + tooltip: this.$t('_timelines.local'), + icon: faComments, + onClick: () => { this.src = 'local'; this.saveSrc(); }, + selected: computed(() => this.src === 'local') + }, { + id: 'social', + title: null, + tooltip: this.$t('_timelines.social'), + icon: faShareAlt, + onClick: () => { this.src = 'social'; this.saveSrc(); }, + selected: computed(() => this.src === 'social') + }, { + id: 'global', + title: null, + tooltip: this.$t('_timelines.global'), + icon: faGlobe, + onClick: () => { this.src = 'global'; this.saveSrc(); }, + selected: computed(() => this.src === 'global') + }, { + id: 'other', + title: null, + icon: faEllipsisH, + onClick: this.choose, + indicate: computed(() => this.$store.state.i.hasUnreadAntenna || this.$store.state.i.hasUnreadChannel) + }], + action: { + icon: faPencilAlt, + handler: () => os.post() + } + }, faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faComments, faListUl, faSatellite, faSatelliteDish, faCircle }; }, @@ -134,11 +162,10 @@ export default Vue.extend({ async choose(ev) { if (this.meta == null) return; - this.menuOpened = true; const [antennas, lists, channels] = await Promise.all([ - this.$root.api('antennas/list'), - this.$root.api('users/lists/list'), - this.$root.api('channels/followed'), + os.api('antennas/list'), + os.api('users/lists/list'), + os.api('channels/followed'), ]); const antennaItems = antennas.map(antenna => ({ text: antenna.name, @@ -171,30 +198,7 @@ export default Vue.extend({ this.$router.push(`/channels/${channel.id}`); } })); - this.$root.menu({ - items: [{ - text: this.$t('_timelines.home'), - icon: faHome, - action: () => { this.src = 'home'; this.saveSrc(); } - }, this.meta.disableLocalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : { - text: this.$t('_timelines.local'), - icon: faComments, - action: () => { this.src = 'local'; this.saveSrc(); } - }, this.meta.disableLocalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : { - text: this.$t('_timelines.social'), - icon: faShareAlt, - action: () => { this.src = 'social'; this.saveSrc(); } - }, this.meta.disableGlobalTimeline && !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin ? undefined : { - text: this.$t('_timelines.global'), - icon: faGlobe, - action: () => { this.src = 'global'; this.saveSrc(); } - }, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems, channelItems.length > 0 ? null : undefined, ...channelItems], - fixed: true, - noCenter: true, - source: ev.currentTarget || ev.target - }).then(() => { - this.menuOpened = false; - }); + os.modalMenu([...antennaItems, listItems.length > 0 ? null : undefined, ...listItems, channelItems.length > 0 ? null : undefined, ...channelItems], ev.currentTarget || ev.target); }, saveSrc() { @@ -222,35 +226,14 @@ export default Vue.extend({ > button { display: block; - margin: 0 auto; + margin: var(--margin) auto 0 auto; padding: 8px 16px; border-radius: 32px; } } - > .tutorial { - margin-bottom: var(--margin); - } - - > .post-form { - position: relative; - margin-bottom: var(--margin); - } -} - -._kjvfvyph_ { - position: relative; - height: 100%; - padding: 0 16px; - font-weight: bold; + > ._section { - > i { - position: absolute; - top: initial; - right: 8px; - color: var(--indicator); - font-size: 12px; - animation: blink 1s infinite; } } </style> diff --git a/src/client/pages/user/follow-list.vue b/src/client/pages/user/follow-list.vue index 666e2d04fe..411109c890 100644 --- a/src/client/pages/user/follow-list.vue +++ b/src/client/pages/user/follow-list.vue @@ -1,31 +1,24 @@ <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"> - <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> - <mk-follow-button class="koudoku-button" v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" mini/> +<div class="_section"> + <MkPagination :pagination="pagination" #default="{items}" class="mk-following-or-followers _content" ref="list"> + <div class="users"> + <MkUserInfo class="user" v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :user="user" :key="user.id"/> </div> - </div> -</mk-pagination> + </MkPagination> +</div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import parseAcct from '../../../misc/acct/parse'; -import MkFollowButton from '../../components/follow-button.vue'; -import MkPagination from '../../components/ui/pagination.vue'; +import MkUserInfo from '@/components/user-info.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import { userPage, acct } from '../../filters/user'; -export default Vue.extend({ +export default defineComponent({ components: { MkPagination, - MkFollowButton, + MkUserInfo, }, props: { @@ -55,83 +48,22 @@ export default Vue.extend({ '$route'() { this.$refs.list.reload(); } + }, + + methods: { + userPage, + + acct } }); </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; - } - } + > .users { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: var(--margin); } } </style> diff --git a/src/client/pages/user/index.activity.vue b/src/client/pages/user/index.activity.vue index 29dcca0664..30c02ec54a 100644 --- a/src/client/pages/user/index.activity.vue +++ b/src/client/pages/user/index.activity.vue @@ -5,10 +5,11 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import ApexCharts from 'apexcharts'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: { user: { type: Object, @@ -28,7 +29,7 @@ export default Vue.extend({ }; }, mounted() { - this.$root.api('charts/user/notes', { + os.api('charts/user/notes', { userId: this.user.id, span: 'day', limit: this.limit diff --git a/src/client/pages/user/index.photos.vue b/src/client/pages/user/index.photos.vue index 83a2618403..dcd4d1fce8 100644 --- a/src/client/pages/user/index.photos.vue +++ b/src/client/pages/user/index.photos.vue @@ -1,11 +1,11 @@ <template> <div class="ujigsodd"> - <mk-loading v-if="fetching"/> + <MkLoading v-if="fetching"/> <div class="stream" v-if="!fetching && images.length > 0"> <router-link v-for="(image, i) in images" :key="i" class="img" :style="`background-image: url(${thumbnail(image.file)})`" - :to="image.note | notePage" + :to="notePage(image.note)" ></router-link> </div> <p class="empty" v-if="!fetching && images.length == 0">{{ $t('nothing') }}</p> @@ -13,10 +13,12 @@ </template> <script lang="ts"> -import Vue from 'vue'; -import { getStaticImageUrl } from '../../scripts/get-static-image-url'; +import { defineComponent } from 'vue'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import notePage from '../../filters/note'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: ['user'], data() { return { @@ -32,7 +34,7 @@ export default Vue.extend({ 'image/apng', 'image/vnd.mozilla.apng', ]; - this.$root.api('users/notes', { + os.api('users/notes', { userId: this.user.id, fileType: image, excludeNsfw: !this.$store.state.device.alwaysShowNsfw, @@ -57,18 +59,17 @@ export default Vue.extend({ ? getStaticImageUrl(image.thumbnailUrl) : image.thumbnailUrl; }, + notePage }, }); </script> <style lang="scss" scoped> .ujigsodd { - > .stream { display: flex; justify-content: center; flex-wrap: wrap; - padding: 8px; > .img { flex: 1 1 33%; @@ -79,7 +80,7 @@ export default Vue.extend({ background-size: cover; background-clip: content-box; border: solid 2px transparent; - border-radius: 4px; + border-radius: 6px; } } diff --git a/src/client/pages/user/index.timeline.vue b/src/client/pages/user/index.timeline.vue index 13ed49ea07..e60feca538 100644 --- a/src/client/pages/user/index.timeline.vue +++ b/src/client/pages/user/index.timeline.vue @@ -5,15 +5,16 @@ <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)"/> + <XNotes 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'; +import { defineComponent } from 'vue'; +import XNotes from '@/components/notes.vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XNotes }, diff --git a/src/client/pages/user/index.vue b/src/client/pages/user/index.vue index 21aa7bece0..bbbf15210b 100644 --- a/src/client/pages/user/index.vue +++ b/src/client/pages/user/index.vue @@ -1,144 +1,160 @@ <template> <div class="mk-user-page" v-if="user" v-size="{ max: [500] }"> - <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> + <MkRemoteCaution v-if="user.host != null" :href="user.url" style="margin-bottom: var(--margin)"/> - <mk-remote-caution v-if="user.host != null" :href="user.url" style="margin-bottom: var(--margin)"/> - <div class="punished _panel" v-if="user.isSuspended"><fa :icon="faExclamationTriangle" style="margin-right: 8px;"/> {{ $t('userSuspended') }}</div> - <div class="punished _panel" v-if="user.isSilenced"><fa :icon="faExclamationTriangle" style="margin-right: 8px;"/> {{ $t('userSilenced') }}</div> - <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> + <!-- TODO --> + <!-- <div class="punished" v-if="user.isSuspended"><Fa :icon="faExclamationTriangle" style="margin-right: 8px;"/> {{ $t('userSuspended') }}</div> --> + <!-- <div class="punished" v-if="user.isSilenced"><Fa :icon="faExclamationTriangle" style="margin-right: 8px;"/> {{ $t('userSilenced') }}</div> --> + + <div class="profile _section _fitBottom"> + <div class="_content" :key="user.id"> + <div class="banner-container" :style="style"> + <div class="banner" ref="banner" :style="style"></div> + <div class="fade"></div> + <div class="title"> + <MkUserName class="name" :user="user" :nowrap="true"/> + <div class="bottom"> + <span class="username"><MkAcct :user="user" :detail="true" /></span> + <span v-if="user.isAdmin" :title="$t('isAdmin')" style="color: var(--badge);"><Fa :icon="faBookmark"/></span> + <span v-if="!user.isAdmin && user.isModerator" :title="$t('isModerator')" style="color: var(--badge);"><Fa :icon="farBookmark"/></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"><Fa :icon="faEllipsisH"/></button> + <MkFollowButton v-if="$store.state.i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> + </div> + </div> + <MkAvatar class="avatar" :user="user" :disable-preview="true"/> <div class="title"> - <mk-user-name class="name" :user="user" :nowrap="true"/> + <MkUserName :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')" style="color: var(--badge);"><fa :icon="faBookmark"/></span> - <span v-if="!user.isAdmin && user.isModerator" :title="$t('isModerator')" style="color: var(--badge);"><fa :icon="farBookmark"/></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> + <span class="username"><MkAcct :user="user" :detail="true" /></span> + <span v-if="user.isAdmin" :title="$t('isAdmin')" style="color: var(--badge);"><Fa :icon="faBookmark"/></span> + <span v-if="!user.isAdmin && user.isModerator" :title="$t('isModerator')" style="color: var(--badge);"><Fa :icon="farBookmark"/></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> - <mk-follow-button v-if="$store.state.i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> + <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> - <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')" style="color: var(--badge);"><fa :icon="faBookmark"/></span> - <span v-if="!user.isAdmin && user.isModerator" :title="$t('isModerator')" style="color: var(--badge);"><fa :icon="farBookmark"/></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 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() }} (<MkTime :time="user.createdAt"/>)</dd> + </dl> + </div> + <div class="fields" v-if="user.fields.length > 0"> + <dl class="field" v-for="(field, i) in user.fields" :key="i"> + <dt class="name"> + <Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/> + </dt> + <dd class="value"> + <Mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :colored="false"/> + </dd> + </dl> + </div> + <div class="status"> + <router-link :to="userPage(user)" :class="{ active: $route.name === 'user' }"> + <b>{{ number(user.notesCount) }}</b> + <span>{{ $t('notes') }}</span> + </router-link> + <router-link :to="userPage(user, 'following')" :class="{ active: $route.name === 'userFollowing' }"> + <b>{{ number(user.followingCount) }}</b> + <span>{{ $t('following') }}</span> + </router-link> + <router-link :to="userPage(user, 'followers')" :class="{ active: $route.name === 'userFollowers' }"> + <b>{{ number(user.followersCount) }}</b> + <span>{{ $t('followers') }}</span> + </router-link> </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"> - <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> + <router-view :user="user"></router-view> <template v-if="$route.name == 'user'"> - <div class="pins"> - <x-note v-for="note in user.pinnedNotes" class="note" :note="note" @updated="pinnedNoteUpdated(note, $event)" :key="note.id" :detail="true" :pinned="true"/> - </div> - <mk-container :body-togglable="true" class="content"> - <template #header><fa :icon="faImage"/>{{ $t('images') }}</template> - <div> - <x-photos :user="user" :key="user.id"/> + <div class="_section" v-if="user.pinnedNotes.length > 0"> + <div class="_content _vMargin"> + <XNote v-for="note in user.pinnedNotes" class="note _vMargin" :note="note" @update:note="pinnedNoteUpdated(note, $event)" :key="note.id" :detail="true" :pinned="true"/> </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"/> + <MkFolder :body-togglable="true" class="_content _vMargin" persist-key="user-images"> + <template #header><Fa :icon="faImage" style="margin-right: 0.5em;"/>{{ $t('images') }}</template> + <div> + <XPhotos :user="user" :key="user.id"/> + </div> + </MkFolder> + <MkFolder :body-togglable="true" class="_content _vMargin" persist-key="user-activity"> + <template #header><Fa :icon="faChartBar" style="margin-right: 0.5em;"/>{{ $t('activity') }}</template> + <div> + <XActivity :user="user" :key="user.id"/> + </div> + </MkFolder> + </div> + <div class="_section"> + <XUserTimeline :user="user" class="_content"/> + </div> </template> </div> <div v-else-if="error"> - <mk-error @retry="fetch()"/> + <MkError @retry="fetch()"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent, defineAsyncComponent, computed } from 'vue'; import { faExclamationTriangle, faEllipsisH, faRobot, faLock, faBookmark, faChartBar, faImage, faBirthdayCake, faMapMarker } from '@fortawesome/free-solid-svg-icons'; import { faCalendarAlt, faBookmark as farBookmark } 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 MkFollowButton from '../../components/follow-button.vue'; -import MkContainer from '../../components/ui/container.vue'; -import MkRemoteCaution from '../../components/remote-caution.vue'; -import Progress from '../../scripts/loading'; +import XNote from '@/components/note.vue'; +import MkFollowButton from '@/components/follow-button.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import MkRemoteCaution from '@/components/remote-caution.vue'; +import Progress from '@/scripts/loading'; import parseAcct from '../../../misc/acct/parse'; -import { getScrollPosition } from '../../scripts/scroll'; +import { getScrollPosition } from '@/scripts/scroll'; +import { getUserMenu } from '@/scripts/get-user-menu'; +import number from '../../filters/number'; +import { userPage, acct } from '../../filters/user'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ components: { XUserTimeline, XNote, MkFollowButton, MkContainer, MkRemoteCaution, - 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 - }; + MkFolder, + XPhotos: defineAsyncComponent(() => import('./index.photos.vue')), + XActivity: defineAsyncComponent(() => import('./index.activity.vue')), }, data() { return { + INFO: computed(() => this.user ? { + header: [{ + userName: this.user, + avatar: this.user, + }], + action: { + icon: faEllipsisH, + handler: this.menu + } + } : null), user: null, error: null, parallaxAnimationId: null, @@ -169,15 +185,17 @@ export default Vue.extend({ mounted() { window.requestAnimationFrame(this.parallaxLoop); - this.$once('hook:beforeDestroy', () => { - window.cancelAnimationFrame(this.parallaxAnimationId); - }); + }, + + beforeUnmount() { + window.cancelAnimationFrame(this.parallaxAnimationId); }, methods: { fetch() { + if (this.$route.params.user == null) return; Progress.start(); - this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => { + os.api('users/show', parseAcct(this.$route.params.user)).then(user => { this.user = user; }).catch(e => { this.error = e; @@ -186,11 +204,8 @@ export default Vue.extend({ }); }, - menu() { - this.$root.new(XUserMenu, { - source: this.$refs.menu, - user: this.user - }); + menu(ev) { + os.modalMenu(getUserMenu(this.user), ev.currentTarget || ev.target); }, parallaxLoop() { @@ -213,8 +228,12 @@ export default Vue.extend({ pinnedNoteUpdated(oldValue, newValue) { const i = this.user.pinnedNotes.findIndex(n => n === oldValue); - Vue.set(this.user.pinnedNotes, i, newValue); + this.user.pinnedNotes[i] = newValue; }, + + number, + + userPage } }); </script> @@ -227,218 +246,214 @@ export default Vue.extend({ } > .profile { - position: relative; - margin-bottom: var(--margin); - overflow: hidden; - - > .banner-container { + > ._content { position: relative; - height: 250px; overflow: hidden; - background-size: cover; - background-position: center; - > .banner { - height: 100%; - background-color: #4c5e6d; + > .banner-container { + position: relative; + height: 250px; + overflow: hidden; background-size: cover; background-position: center; - box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; - will-change: background-position; - } + border-radius: 12px; - > .fade { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 78px; - background: linear-gradient(transparent, rgba(#000, 0.7)); - } + > .banner { + height: 100%; + background-color: #4c5e6d; + background-size: cover; + background-position: center; + box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; + will-change: background-position; + } - > .followed { - position: absolute; - top: 12px; - left: 12px; - padding: 4px 8px; - color: #fff; - background: rgba(0, 0, 0, 0.7); - font-size: 0.7em; - border-radius: 6px; - } + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 78px; + background: linear-gradient(transparent, rgba(#000, 0.7)); + } - > .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; + > .followed { + position: absolute; + top: 12px; + left: 12px; + padding: 4px 8px; color: #fff; - text-shadow: 0 0 8px #000; - font-size: 16px; + background: rgba(0, 0, 0, 0.7); + font-size: 0.7em; + border-radius: 6px; } - > .koudoku { - margin-left: 4px; - vertical-align: bottom; - } - } + > .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; - > .title { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - padding: 0 0 8px 154px; - box-sizing: border-box; - color: #fff; + > .menu { + vertical-align: bottom; + height: 31px; + width: 31px; + color: #fff; + text-shadow: 0 0 8px #000; + font-size: 16px; + } - > .name { - display: block; - margin: 0; - line-height: 32px; - font-weight: bold; - font-size: 1.8em; - text-shadow: 0 0 8px #000; + > .koudoku { + margin-left: 4px; + vertical-align: bottom; + } } - > .bottom { - > * { - display: inline-block; - margin-right: 16px; - line-height: 20px; - opacity: 0.8; + > .title { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 0 0 8px 154px; + box-sizing: border-box; + color: #fff; + + > .name { + display: block; + margin: 0; + line-height: 32px; + font-weight: bold; + font-size: 1.8em; + text-shadow: 0 0 8px #000; + } + + > .bottom { + > * { + display: inline-block; + margin-right: 16px; + line-height: 20px; + opacity: 0.8; - &.username { - font-weight: bold; + &.username { + font-weight: bold; + } } } } } - } - > .title { - display: none; - text-align: center; - padding: 50px 8px 16px 8px; - font-weight: bold; - border-bottom: solid 1px var(--divider); + > .title { + display: none; + text-align: center; + padding: 50px 8px 16px 8px; + font-weight: bold; + border-bottom: solid 1px var(--divider); - > .bottom { - > * { - display: inline-block; - margin-right: 8px; - opacity: 0.8; + > .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); - } + > .avatar { + display: block; + position: absolute; + top: 170px; + left: 16px; + z-index: 2; + width: 120px; + height: 120px; + box-shadow: 1px 1px 3px rgba(#000, 0.2); + } - > .description { - padding: 24px 24px 24px 154px; - font-size: 0.95em; + > .description { + padding: 24px 24px 24px 154px; + font-size: 0.95em; - > .empty { - margin: 0; - opacity: 0.5; + > .empty { + margin: 0; + opacity: 0.5; + } } - } - > .fields { - padding: 24px; - font-size: 0.9em; - border-top: solid 1px var(--divider); + > .fields { + padding: 24px; + font-size: 0.9em; + border-top: solid 1px var(--divider); - > .field { - display: flex; - padding: 0; - margin: 0; - align-items: center; + > .field { + display: flex; + padding: 0; + margin: 0; + align-items: center; - &:not(:last-child) { - margin-bottom: 8px; - } + &:not(:last-child) { + margin-bottom: 8px; + } - > .name { - width: 30%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - font-weight: bold; - text-align: center; - } + > .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; + > .value { + width: 70%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } } - } - &.system > .field > .name { + &.system > .field > .name { + } } - } - > .status { - display: flex; - padding: 24px; - border-top: solid 1px var(--divider); + > .status { + display: flex; + padding: 24px; + border-top: solid 1px var(--divider); - > a { - flex: 1; - text-align: center; + > a { + flex: 1; + text-align: center; - &.active { - color: var(--accent); - } + &.active { + color: var(--accent); + } - &:hover { - text-decoration: none; - } + &:hover { + text-decoration: none; + } - > b { - display: block; - line-height: 16px; - } + > b { + display: block; + line-height: 16px; + } - > span { - font-size: 70%; + > span { + font-size: 70%; + } } } } } - > .pins { - > .note { - margin-bottom: var(--margin); - } - } - > .content { margin-bottom: var(--margin); } &.max-width_500px { - > .profile { + > .profile > ._content { > .banner-container { height: 140px; diff --git a/src/client/pages/welcome.entrance.vue b/src/client/pages/welcome.entrance.vue new file mode 100644 index 0000000000..ff946f7452 --- /dev/null +++ b/src/client/pages/welcome.entrance.vue @@ -0,0 +1,89 @@ +<template> +<div class="rsqzvsbo"> + <div class="_section"> + <div class="_content _panel about" v-if="meta"> + <div class="body"> + <div class="desc" v-html="meta.description || $t('introMisskey')"></div> + <MkButton @click="signup()" style="display: inline-block; margin-right: 16px;" primary>{{ $t('signup') }}</MkButton> + <MkButton @click="signin()" style="display: inline-block;">{{ $t('login') }}</MkButton> + </div> + </div> + </div> + <div class="_section"> + <div class="_content"> + <XNotes :pagination="featuredPagination"/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { toUnicode } from 'punycode'; +import XSigninDialog from '@/components/signin-dialog.vue'; +import XSignupDialog from '@/components/signup-dialog.vue'; +import MkButton from '@/components/ui/button.vue'; +import XNotes from '@/components/notes.vue'; +import { host } from '@/config'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + XNotes, + }, + + data() { + return { + featuredPagination: { + endpoint: 'notes/featured', + limit: 10, + noPaging: true, + }, + host: toUnicode(host), + }; + }, + + computed: { + meta() { + return this.$store.state.instance.meta; + }, + }, + + created() { + os.api('stats').then(stats => { + this.stats = stats; + }); + }, + + methods: { + signin() { + os.popup(XSigninDialog, { + autoSet: true + }, {}, 'closed'); + }, + + signup() { + os.popup(XSignupDialog, { + autoSet: true + }, {}, 'closed'); + } + } +}); +</script> + +<style lang="scss" scoped> +.rsqzvsbo { + > ._section { + > .about { + > .body { + padding: 32px; + + @media (max-width: 500px) { + padding: 16px; + } + } + } + } +} +</style> diff --git a/src/client/pages/index.welcome.setup.vue b/src/client/pages/welcome.setup.vue index 9a66a4dffb..ef39a4ca06 100644 --- a/src/client/pages/index.welcome.setup.vue +++ b/src/client/pages/welcome.setup.vue @@ -3,31 +3,31 @@ <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> + <MkInput v-model:value="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"> + </MkInput> + <MkInput v-model:value="password" type="password"> <span>{{ $t('password') }}</span> - <template #prefix><fa :icon="faLock"/></template> - </mk-input> + <template #prefix><Fa :icon="faLock"/></template> + </MkInput> <footer> - <mk-button primary type="submit" :disabled="submitting">{{ submitting ? $t('processing') : $t('done') }}<mk-ellipsis v-if="submitting"/></mk-button> + <MkButton primary type="submit" :disabled="submitting">{{ submitting ? $t('processing') : $t('done') }}<MkEllipsis v-if="submitting"/></MkButton> </footer> </div> </form> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } 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 MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import { host } from '@/config'; +import * as os from '@/os'; -export default Vue.extend({ - +export default defineComponent({ components: { MkButton, MkInput, @@ -48,7 +48,7 @@ export default Vue.extend({ if (this.submitting) return; this.submitting = true; - this.$root.api('admin/accounts/create', { + os.api('admin/accounts/create', { username: this.username, password: this.password, }).then(res => { @@ -57,9 +57,9 @@ export default Vue.extend({ }).catch(() => { this.submitting = false; - this.$root.dialog({ + os.dialog({ type: 'error', - text: this.$t('error') + text: this.$t('somethingHappened') }); }); } diff --git a/src/client/pages/welcome.vue b/src/client/pages/welcome.vue new file mode 100644 index 0000000000..fb130cba5c --- /dev/null +++ b/src/client/pages/welcome.vue @@ -0,0 +1,37 @@ +<template> +<div v-if="meta"> + <XSetup v-if="meta.requireSetup"/> + <XEntrance v-else/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XSetup from './welcome.setup.vue'; +import XEntrance from './welcome.entrance.vue'; +import { instanceName } from '@/config'; + +export default defineComponent({ + components: { + XSetup, + XEntrance, + }, + + data() { + return { + INFO: { + header: [{ + title: instanceName || 'Misskey', + icon: null + }] + }, + } + }, + + computed: { + meta() { + return this.$store.state.instance.meta; + }, + }, +}); +</script> diff --git a/src/client/plugin.ts b/src/client/plugin.ts new file mode 100644 index 0000000000..9d1ef87c1a --- /dev/null +++ b/src/client/plugin.ts @@ -0,0 +1,124 @@ +import { AiScript, utils, values } from '@syuilo/aiscript'; +import { deserialize } from '@syuilo/aiscript/built/serializer'; +import { jsToVal } from '@syuilo/aiscript/built/interpreter/util'; +import { createAiScriptEnv } from '@/scripts/aiscript/api'; +import { dialog } from '@/os'; +import { noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions } from '@/store'; + +const pluginContexts = new Map<string, AiScript>(); + +export function install(plugin) { + console.info('Plugin installed:', plugin.name, 'v' + plugin.version); + + const aiscript = new AiScript(createPluginEnv({ + plugin: plugin, + storageKey: 'plugins:' + plugin.id + }), { + in: (q) => { + return new Promise(ok => { + dialog({ + title: q, + input: {} + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + console.log(value); + }, + log: (type, params) => { + }, + }); + + initPlugin({ plugin, aiscript }); + + aiscript.exec(deserialize(plugin.ast)); +} + +function createPluginEnv(opts) { + const config = new Map(); + for (const [k, v] of Object.entries(opts.plugin.config || {})) { + config.set(k, jsToVal(opts.plugin.configData[k] || v.default)); + } + + return { + ...createAiScriptEnv({ ...opts, token: opts.plugin.token }), + //#region Deprecated + 'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => { + registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Mk:register_user_action': values.FN_NATIVE(([title, handler]) => { + registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => { + registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + //#endregion + 'Plugin:register_post_form_action': values.FN_NATIVE(([title, handler]) => { + registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Plugin:register_user_action': values.FN_NATIVE(([title, handler]) => { + registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Plugin:register_note_action': values.FN_NATIVE(([title, handler]) => { + registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Plugin:register_note_view_interruptor': values.FN_NATIVE(([handler]) => { + registerNoteViewInterruptor({ pluginId: opts.plugin.id, handler }); + }), + 'Plugin:register_note_post_interruptor': values.FN_NATIVE(([handler]) => { + registerNotePostInterruptor({ pluginId: opts.plugin.id, handler }); + }), + 'Plugin:open_url': values.FN_NATIVE(([url]) => { + window.open(url.value, '_blank'); + }), + 'Plugin:config': values.OBJ(config), + }; +} + +function initPlugin({ plugin, aiscript }) { + pluginContexts.set(plugin.id, aiscript); +} + +function registerPostFormAction({ pluginId, title, handler }) { + postFormActions.push({ + title, handler: (form, update) => { + pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => { + update(key.value, value.value); + })]); + } + }); +} + +function registerUserAction({ pluginId, title, handler }) { + userActions.push({ + title, handler: (user) => { + pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(user)]); + } + }); +} + +function registerNoteAction({ pluginId, title, handler }) { + noteActions.push({ + title, handler: (note) => { + pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]); + } + }); +} + +function registerNoteViewInterruptor({ pluginId, handler }) { + noteViewInterruptors.push({ + handler: async (note) => { + return utils.valToJs(await pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)])); + } + }); +} + +function registerNotePostInterruptor({ pluginId, handler }) { + notePostInterruptors.push({ + handler: async (note) => { + return utils.valToJs(await pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)])); + } + }); +} diff --git a/src/client/root.vue b/src/client/root.vue new file mode 100644 index 0000000000..0bca5cbe8c --- /dev/null +++ b/src/client/root.vue @@ -0,0 +1,75 @@ +<template> +<ZenUI v-if="zen"/> +<VisitorUI v-else-if="!$store.getters.isSignedIn"/> +<DeckUI v-else-if="deckmode"/> +<DefaultUI v-else/> + +<component v-for="popup in popups" + :key="popup.id" + :is="popup.component" + v-bind="popup.props" + v-on="popup.events" +/> + +<XUpload v-if="uploads.length > 0"/> + +<div id="wait" v-if="pendingApiRequestsCount > 0"></div> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import { deckmode } from '@/config'; +import { popups, uploads, pendingApiRequestsCount } from '@/os'; + +export default defineComponent({ + components: { + DefaultUI: defineAsyncComponent(() => import('@/ui/default.vue')), + DeckUI: defineAsyncComponent(() => import('@/ui/deck.vue')), + ZenUI: defineAsyncComponent(() => import('@/ui/zen.vue')), + VisitorUI: defineAsyncComponent(() => import('@/ui/visitor.vue')), + XUpload: defineAsyncComponent(() => import('@/components/upload.vue')), + }, + + setup() { + return { + zen: window.location.search === '?zen', + deckmode, + uploads, + popups, + pendingApiRequestsCount, + }; + }, +}); +</script> + +<style lang="scss"> +#wait { + display: block; + position: fixed; + z-index: 10000; + top: 15px; + right: 15px; + + &:before { + content: ""; + display: block; + width: 18px; + height: 18px; + box-sizing: border-box; + border: solid 2px transparent; + border-top-color: var(--accent); + border-left-color: var(--accent); + border-radius: 50%; + animation: progress-spinner 400ms linear infinite; + } +} + +@keyframes progress-spinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} +</style> diff --git a/src/client/router.ts b/src/client/router.ts index c506dd6be0..fc67f6ecfd 100644 --- a/src/client/router.ts +++ b/src/client/router.ts @@ -1,17 +1,23 @@ -import Vue from 'vue'; -import VueRouter from 'vue-router'; -import MkIndex from './pages/index.vue'; +import { defineAsyncComponent } from 'vue'; +import { createRouter, createWebHistory } from 'vue-router'; +import MkLoading from '@/pages/_loading_.vue'; +import MkError from '@/pages/_error_.vue'; +import MkTimeline from '@/pages/timeline.vue'; +import { store } from './store'; -Vue.use(VueRouter); - -const page = (path: string) => () => import(`./pages/${path}.vue`).then(m => m.default); +const page = (path: string) => defineAsyncComponent({ + loader: () => import(`./pages/${path}.vue`), + loadingComponent: MkLoading, + errorComponent: MkError, +}); let indexScrollPos = 0; -export const router = new VueRouter({ - mode: 'history', +export const router = createRouter({ + history: createWebHistory(), routes: [ - { path: '/', name: 'index', component: MkIndex }, + // NOTE: MkTimelineをdynamic importするとAsyncComponentWrapperが間に入るせいでkeep-aliveのコンポーネント指定が効かなくなる + { path: '/', name: 'index', component: store.getters.isSignedIn ? MkTimeline : page('welcome') }, { path: '/@:user', name: 'user', component: page('user/index'), children: [ { path: 'following', name: 'userFollowing', component: page('user/follow-list'), props: { type: 'following' } }, { path: 'followers', name: 'userFollowers', component: page('user/follow-list'), props: { type: 'followers' } }, @@ -19,6 +25,23 @@ export const router = new VueRouter({ { path: '/@:user/pages/:page', component: page('page'), props: route => ({ pageName: route.params.page, username: route.params.user }) }, { path: '/@:user/pages/:pageName/view-source', component: page('page-editor/page-editor'), props: route => ({ initUser: route.params.user, initPageName: route.params.pageName }) }, { path: '/@:acct/room', props: true, component: page('room/room') }, + { path: '/settings', name: 'settings', component: page('settings/index'), children: [ + { path: 'profile', component: page('settings/profile') }, + { path: 'privacy', component: page('settings/privacy') }, + { path: 'reaction', component: page('settings/reaction') }, + { path: 'notifications', component: page('settings/notifications') }, + { path: 'mute-block', component: page('settings/mute-block') }, + { path: 'word-mute', component: page('settings/word-mute') }, + { path: 'integration', component: page('settings/integration') }, + { path: 'security', component: page('settings/security') }, + { path: 'api', component: page('settings/api') }, + { path: 'other', component: page('settings/other') }, + { path: 'general', component: page('settings/general') }, + { path: 'theme', component: page('settings/theme') }, + { path: 'sidebar', component: page('settings/sidebar') }, + { path: 'sounds', component: page('settings/sounds') }, + { path: 'plugins', component: page('settings/plugins') }, + ]}, { path: '/announcements', component: page('announcements') }, { path: '/about', component: page('about') }, { path: '/about-misskey', component: page('about-misskey') }, @@ -38,14 +61,13 @@ export const router = new VueRouter({ { path: '/my/messages', component: page('messages') }, { path: '/my/mentions', component: page('mentions') }, { path: '/my/messaging', name: 'messaging', component: page('messaging/index') }, - { path: '/my/messaging/:user', component: page('messaging/messaging-room') }, - { path: '/my/messaging/group/:group', component: page('messaging/messaging-room') }, + { path: '/my/messaging/:user', component: page('messaging/messaging-room'), props: route => ({ userAcct: route.params.user }) }, + { path: '/my/messaging/group/:group', component: page('messaging/messaging-room'), props: route => ({ groupId: route.params.group }) }, { path: '/my/drive', name: 'drive', component: page('drive') }, { path: '/my/drive/folder/:folder', component: page('drive') }, { path: '/my/pages', name: 'pages', component: page('pages') }, { path: '/my/pages/new', component: page('page-editor/page-editor') }, { path: '/my/pages/edit/:pageId', component: page('page-editor/page-editor'), props: route => ({ initPageId: route.params.pageId }) }, - { path: '/my/settings', component: page('my-settings/index') }, { path: '/my/follow-requests', component: page('follow-requests') }, { path: '/my/lists', component: page('my-lists/index') }, { path: '/my/lists/:list', component: page('my-lists/list') }, @@ -53,12 +75,11 @@ export const router = new VueRouter({ { path: '/my/groups/:group', component: page('my-groups/group') }, { path: '/my/antennas', component: page('my-antennas/index') }, { path: '/my/apps', component: page('apps') }, - { path: '/preferences', component: page('preferences/index') }, { path: '/scratchpad', component: page('scratchpad') }, { path: '/instance', component: page('instance/index') }, { path: '/instance/emojis', component: page('instance/emojis') }, { path: '/instance/users', component: page('instance/users') }, - { path: '/instance/users/:user', component: page('instance/users.user') }, + { path: '/instance/logs', component: page('instance/logs') }, { path: '/instance/files', component: page('instance/files') }, { path: '/instance/queue', component: page('instance/queue') }, { path: '/instance/settings', component: page('instance/settings') }, @@ -71,7 +92,8 @@ export const router = new VueRouter({ { path: '/miauth/:session', component: page('miauth') }, { path: '/authorize-follow', component: page('follow') }, { path: '/share', component: page('share') }, - { path: '*', component: page('not-found') } + { path: '/test', component: page('test') }, + { path: '/:catchAll(.*)', component: page('not-found') } ], // なんかHacky // 通常の使い方をすると scroll メソッドの behavior を設定できないため、自前で window.scroll するようにする diff --git a/src/client/scripts/aiscript/api.ts b/src/client/scripts/aiscript/api.ts index 7e3a668871..f5618bd14c 100644 --- a/src/client/scripts/aiscript/api.ts +++ b/src/client/scripts/aiscript/api.ts @@ -1,22 +1,22 @@ import { utils, values } from '@syuilo/aiscript'; -import { jsToVal } from '@syuilo/aiscript/built/interpreter/util'; +import { store } from '@/store'; +import * as os from '@/os'; -// TODO: vue3に移行した折にはvmを渡す必要は無くなるはず -export function createAiScriptEnv(vm, opts) { +export function createAiScriptEnv(opts) { let apiRequests = 0; return { - USER_ID: vm.$store.getters.isSignedIn ? values.STR(vm.$store.state.i.id) : values.NULL, - USER_NAME: vm.$store.getters.isSignedIn ? values.STR(vm.$store.state.i.name) : values.NULL, - USER_USERNAME: vm.$store.getters.isSignedIn ? values.STR(vm.$store.state.i.username) : values.NULL, + USER_ID: store.getters.isSignedIn ? values.STR(store.state.i.id) : values.NULL, + USER_NAME: store.getters.isSignedIn ? values.STR(store.state.i.name) : values.NULL, + USER_USERNAME: store.getters.isSignedIn ? values.STR(store.state.i.username) : values.NULL, 'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => { - await vm.$root.dialog({ + await os.dialog({ type: type ? type.value : 'info', title: title.value, text: text.value, }); }), 'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => { - const confirm = await vm.$root.dialog({ + const confirm = await os.dialog({ type: type ? type.value : 'question', showCancelButton: true, title: title.value, @@ -28,7 +28,7 @@ export function createAiScriptEnv(vm, opts) { if (token) utils.assertString(token); apiRequests++; if (apiRequests > 16) return values.NULL; - const res = await vm.$root.api(ep.value, utils.valToJs(param), token ? token.value : (opts.token || null)); + const res = await os.api(ep.value, utils.valToJs(param), token ? token.value : (opts.token || null)); return utils.jsToVal(res); }), 'Mk:save': values.FN_NATIVE(([key, value]) => { @@ -42,45 +42,3 @@ export function createAiScriptEnv(vm, opts) { }), }; } - -// TODO: vue3に移行した折にはvmを渡す必要は無くなるはず -export function createPluginEnv(vm, opts) { - const config = new Map(); - for (const [k, v] of Object.entries(opts.plugin.config || {})) { - config.set(k, jsToVal(opts.plugin.configData[k] || v.default)); - } - - return { - ...createAiScriptEnv(vm, { ...opts, token: opts.plugin.token }), - //#region Deprecated - 'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => { - vm.$store.commit('registerPostFormAction', { pluginId: opts.plugin.id, title: title.value, handler }); - }), - 'Mk:register_user_action': values.FN_NATIVE(([title, handler]) => { - vm.$store.commit('registerUserAction', { pluginId: opts.plugin.id, title: title.value, handler }); - }), - 'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => { - vm.$store.commit('registerNoteAction', { pluginId: opts.plugin.id, title: title.value, handler }); - }), - //#endregion - 'Plugin:register_post_form_action': values.FN_NATIVE(([title, handler]) => { - vm.$store.commit('registerPostFormAction', { pluginId: opts.plugin.id, title: title.value, handler }); - }), - 'Plugin:register_user_action': values.FN_NATIVE(([title, handler]) => { - vm.$store.commit('registerUserAction', { pluginId: opts.plugin.id, title: title.value, handler }); - }), - 'Plugin:register_note_action': values.FN_NATIVE(([title, handler]) => { - vm.$store.commit('registerNoteAction', { pluginId: opts.plugin.id, title: title.value, handler }); - }), - 'Plugin:register_note_view_interruptor': values.FN_NATIVE(([handler]) => { - vm.$store.commit('registerNoteViewInterruptor', { pluginId: opts.plugin.id, handler }); - }), - 'Plugin:register_note_post_interruptor': values.FN_NATIVE(([handler]) => { - vm.$store.commit('registerNotePostInterruptor', { pluginId: opts.plugin.id, handler }); - }), - 'Plugin:open_url': values.FN_NATIVE(([url]) => { - window.open(url.value, '_blank'); - }), - 'Plugin:config': values.OBJ(config), - }; -} diff --git a/src/client/directives/autocomplete.ts b/src/client/scripts/autocomplete.ts index 44043017ef..444f416156 100644 --- a/src/client/directives/autocomplete.ts +++ b/src/client/scripts/autocomplete.ts @@ -1,24 +1,15 @@ +import { Ref, ref } from 'vue'; import * as getCaretCoordinates from 'textarea-caret'; import { toASCII } from 'punycode'; +import { popup } from '@/os'; -export default { - bind(el, binding, vn) { - const self = el._autoCompleteDirective_ = {} as any; - self.x = new Autocomplete(el, vn.context, binding.value); - self.x.attach(); - }, - - unbind(el, binding, vn) { - const self = el._autoCompleteDirective_; - self.x.detach(); - } -}; - -/** - * オートコンプリートを管理するクラス。 - */ -class Autocomplete { - private suggestion: any; +export class Autocomplete { + private suggestion: { + x: Ref<number>; + y: Ref<number>; + q: Ref<string>; + close: Function; + }; private textarea: any; private vm: any; private currentType: string; @@ -50,6 +41,8 @@ class Autocomplete { this.vm = vm; this.opts = opts; this.opening = false; + + this.attach(); } /** @@ -147,31 +140,37 @@ class Autocomplete { //#endregion if (this.suggestion) { - // TODO: Vueの警告が出るのでなんとかする - this.suggestion.x = x; - this.suggestion.y = y; - this.suggestion.q = q; + this.suggestion.x.value = x; + this.suggestion.y.value = y; + this.suggestion.q.value = q; this.opening = false; } else { - const MkAutocomplete = await import('../components/autocomplete.vue').then(m => m.default); + const MkAutocomplete = await import('@/components/autocomplete.vue'); + + const _x = ref(x); + const _y = ref(y); + const _q = ref(q); - // サジェスト要素作成 - this.suggestion = new MkAutocomplete({ - parent: this.vm, - propsData: { - textarea: this.textarea, - complete: this.complete, - close: this.close, - type: type, - q: q, - x, - y + const { dispose } = popup(MkAutocomplete, { + textarea: this.textarea, + close: this.close, + type: type, + q: _q, + x: _x, + y: _y, + }, { + done: (res) => { + this.complete(res); } - }).$mount(); + }); - // 要素追加 - document.body.appendChild(this.suggestion.$el); + this.suggestion = { + q: _q, + x: _x, + y: _y, + close: () => dispose(), + }; this.opening = false; } @@ -183,7 +182,7 @@ class Autocomplete { private close() { if (this.suggestion == null) return; - this.suggestion.destroyDom(); + this.suggestion.close(); this.suggestion = null; this.textarea.focus(); @@ -192,7 +191,7 @@ class Autocomplete { /** * オートコンプリートする */ - private complete(type, value) { + private complete({ type, value }) { this.close(); const caret = this.textarea.selectionStart; diff --git a/src/client/scripts/extract-avg-color-from-blurhash.ts b/src/client/scripts/extract-avg-color-from-blurhash.ts new file mode 100644 index 0000000000..123ab7a06d --- /dev/null +++ b/src/client/scripts/extract-avg-color-from-blurhash.ts @@ -0,0 +1,9 @@ +export function extractAvgColorFromBlurhash(hash: string) { + return typeof hash == 'string' + ? '#' + [...hash.slice(2, 6)] + .map(x => '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'.indexOf(x)) + .reduce((a, c) => a * 83 + c, 0) + .toString(16) + .padStart(6, '0') + : undefined; +} diff --git a/src/client/scripts/focus.ts b/src/client/scripts/focus.ts index a2a8516d36..0894877820 100644 --- a/src/client/scripts/focus.ts +++ b/src/client/scripts/focus.ts @@ -1,21 +1,25 @@ -export function focusPrev(el: Element | null, self = false) { +export function focusPrev(el: Element | null, self = false, scroll = true) { if (el == null) return; if (!self) el = el.previousElementSibling; if (el) { if (el.hasAttribute('tabindex')) { - (el as HTMLElement).focus(); + (el as HTMLElement).focus({ + preventScroll: !scroll + }); } else { focusPrev(el.previousElementSibling, true); } } } -export function focusNext(el: Element | null, self = false) { +export function focusNext(el: Element | null, self = false, scroll = true) { if (el == null) return; if (!self) el = el.nextElementSibling; if (el) { if (el.hasAttribute('tabindex')) { - (el as HTMLElement).focus(); + (el as HTMLElement).focus({ + preventScroll: !scroll + }); } else { focusPrev(el.nextElementSibling, true); } diff --git a/src/client/scripts/gen-search-query.ts b/src/client/scripts/gen-search-query.ts index 2520da75df..670d915104 100644 --- a/src/client/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 { host as localHost } from '@/config'; export async function genSearchQuery(v: any, q: string) { let host: string; @@ -13,7 +13,7 @@ export async function genSearchQuery(v: any, q: string) { host = at; } } else { - const user = await v.$root.api('users/show', parseAcct(at)).catch(x => null); + const user = await v.os.api('users/show', parseAcct(at)).catch(x => null); if (user) { userId = user.id; } else { diff --git a/src/client/scripts/get-static-image-url.ts b/src/client/scripts/get-static-image-url.ts index eff76af256..e932eb6da5 100644 --- a/src/client/scripts/get-static-image-url.ts +++ b/src/client/scripts/get-static-image-url.ts @@ -1,4 +1,4 @@ -import { url as instanceUrl } from '../config'; +import { url as instanceUrl } from '@/config'; import * as url from '../../prelude/url'; export function getStaticImageUrl(baseUrl: string): string { diff --git a/src/client/scripts/get-user-menu.ts b/src/client/scripts/get-user-menu.ts new file mode 100644 index 0000000000..63c3ae43b6 --- /dev/null +++ b/src/client/scripts/get-user-menu.ts @@ -0,0 +1,194 @@ +import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments, faUsers, faMicrophoneSlash, faPlug } from '@fortawesome/free-solid-svg-icons'; +import { faSnowflake, faEnvelope } from '@fortawesome/free-regular-svg-icons'; +import { i18n } from '@/i18n'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { host } from '@/config'; +import getAcct from '../../misc/acct/render'; +import * as os from '@/os'; +import { store, userActions } from '@/store'; +import { router } from '@/router'; +import { defineAsyncComponent } from 'vue'; +import { popout } from './popout'; + +export function getUserMenu(user) { + async function pushList() { + const t = i18n.global.t('selectList'); // なぜか後で参照すると null になるので最初にメモリに確保しておく + const lists = await os.api('users/lists/list'); + if (lists.length === 0) { + os.dialog({ + type: 'error', + text: i18n.global.t('youHaveNoLists') + }); + return; + } + const { canceled, result: listId } = await os.dialog({ + type: null, + title: t, + select: { + items: lists.map(list => ({ + value: list.id, text: list.name + })) + }, + showCancelButton: true + }); + if (canceled) return; + os.apiWithDialog('users/lists/push', { + listId: listId, + userId: user.id + }); + } + + async function inviteGroup() { + const groups = await os.api('users/groups/owned'); + if (groups.length === 0) { + os.dialog({ + type: 'error', + text: i18n.global.t('youHaveNoGroups') + }); + return; + } + const { canceled, result: groupId } = await os.dialog({ + type: null, + title: i18n.global.t('group'), + select: { + items: groups.map(group => ({ + value: group.id, text: group.name + })) + }, + showCancelButton: true + }); + if (canceled) return; + os.apiWithDialog('users/groups/invite', { + groupId: groupId, + userId: user.id + }); + } + + async function toggleMute() { + os.apiWithDialog(user.isMuted ? 'mute/delete' : 'mute/create', { + userId: user.id + }).then(() => { + user.isMuted = !user.isMuted; + }); + } + + async function toggleBlock() { + if (!await getConfirmed(user.isBlocking ? i18n.global.t('unblockConfirm') : i18n.global.t('blockConfirm'))) return; + + os.apiWithDialog(user.isBlocking ? 'blocking/delete' : 'blocking/create', { + userId: user.id + }).then(() => { + user.isBlocking = !user.isBlocking; + }); + } + + async function toggleSilence() { + if (!await getConfirmed(i18n.global.t(user.isSilenced ? 'unsilenceConfirm' : 'silenceConfirm'))) return; + + os.apiWithDialog(user.isSilenced ? 'admin/unsilence-user' : 'admin/silence-user', { + userId: user.id + }).then(() => { + user.isSilenced = !user.isSilenced; + }); + } + + async function toggleSuspend() { + if (!await getConfirmed(i18n.global.t(user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return; + + os.apiWithDialog(user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', { + userId: user.id + }).then(() => { + user.isSuspended = !user.isSuspended; + }); + } + + async function getConfirmed(text: string): Promise<boolean> { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + title: 'confirm', + text, + }); + + return !confirm.canceled; + } + + let menu = [{ + icon: faAt, + text: i18n.global.t('copyUsername'), + action: () => { + copyToClipboard(`@${user.username}@${user.host || host}`); + } + }, { + icon: faEnvelope, + text: i18n.global.t('sendMessage'), + action: () => { + os.post({ specified: user }); + } + }, store.state.i.id != user.id ? { + icon: faComments, + text: i18n.global.t('startMessaging'), + action: () => { + const acct = getAcct(user); + switch (store.state.device.chatOpenBehavior) { + case 'window': { os.pageWindow('/my/messaging/' + acct, defineAsyncComponent(() => import('@/pages/messaging/messaging-room.vue')), { userAcct: acct }); break; } + case 'popout': { popout('/my/messaging'); break; } + default: { router.push('/my/messaging'); break; } + } + } + } : undefined, null, { + icon: faListUl, + text: i18n.global.t('addToList'), + action: pushList + }, store.state.i.id != user.id ? { + icon: faUsers, + text: i18n.global.t('inviteToGroup'), + action: inviteGroup + } : undefined] as any; + + if (store.getters.isSignedIn && store.state.i.id != user.id) { + menu = menu.concat([null, { + icon: user.isMuted ? faEye : faEyeSlash, + text: user.isMuted ? i18n.global.t('unmute') : i18n.global.t('mute'), + action: toggleMute + }, { + icon: faBan, + text: user.isBlocking ? i18n.global.t('unblock') : i18n.global.t('block'), + action: toggleBlock + }]); + + if (store.getters.isSignedIn && (store.state.i.isAdmin || store.state.i.isModerator)) { + menu = menu.concat([null, { + icon: faMicrophoneSlash, + text: user.isSilenced ? i18n.global.t('unsilence') : i18n.global.t('silence'), + action: toggleSilence + }, { + icon: faSnowflake, + text: user.isSuspended ? i18n.global.t('unsuspend') : i18n.global.t('suspend'), + action: toggleSuspend + }]); + } + } + + if (store.getters.isSignedIn && store.state.i.id === user.id) { + menu = menu.concat([null, { + icon: faPencilAlt, + text: i18n.global.t('editProfile'), + action: () => { + router.push('/settings/profile'); + } + }]); + } + + if (userActions.length > 0) { + menu = menu.concat([null, ...userActions.map(action => ({ + icon: faPlug, + text: action.title, + action: () => { + action.handler(user); + } + }))]); + } + + return menu; +} diff --git a/src/client/scripts/hpml/evaluator.ts b/src/client/scripts/hpml/evaluator.ts index a056884368..01a122c0e4 100644 --- a/src/client/scripts/hpml/evaluator.ts +++ b/src/client/scripts/hpml/evaluator.ts @@ -1,11 +1,12 @@ import autobind from 'autobind-decorator'; import * as seedrandom from 'seedrandom'; import { Variable, PageVar, envVarsDef, funcDefs, Block, isFnBlock } from '.'; -import { version } from '../../config'; +import { version } from '@/config'; import { AiScript, utils, values } from '@syuilo/aiscript'; import { createAiScriptEnv } from '../aiscript/api'; import { collectPageVars } from '../collect-page-vars'; import { initLib } from './lib'; +import * as os from '@/os'; type Fn = { slots: string[]; @@ -30,19 +31,19 @@ export class Hpml { enableAiScript: boolean; }; - constructor(vm: any, page: Hpml['page'], opts: Hpml['opts']) { + constructor(page: Hpml['page'], opts: Hpml['opts']) { this.page = page; this.variables = this.page.variables; this.pageVars = collectPageVars(this.page.content); this.opts = opts; if (this.opts.enableAiScript) { - this.aiscript = new AiScript({ ...createAiScriptEnv(vm, { + this.aiscript = new AiScript({ ...createAiScriptEnv({ storageKey: 'pages:' + this.page.id }), ...initLib(this)}, { in: (q) => { return new Promise(ok => { - vm.$root.dialog({ + os.dialog({ title: q, input: {} }).then(({ canceled, result: a }) => { diff --git a/src/client/scripts/loading.ts b/src/client/scripts/loading.ts index 70a3a4c85e..4b0a560e34 100644 --- a/src/client/scripts/loading.ts +++ b/src/client/scripts/loading.ts @@ -1,21 +1,11 @@ -import * as NProgress from 'nprogress'; -NProgress.configure({ - trickleSpeed: 500, - showSpinner: false -}); - -const root = document.getElementsByTagName('html')[0]; - export default { start: () => { - root.classList.add('progress'); - NProgress.start(); + // TODO }, done: () => { - root.classList.remove('progress'); - NProgress.done(); + // TODO }, set: val => { - NProgress.set(val); + // TODO } }; diff --git a/src/client/scripts/paging.ts b/src/client/scripts/paging.ts index 538615afa1..3d9668f108 100644 --- a/src/client/scripts/paging.ts +++ b/src/client/scripts/paging.ts @@ -1,8 +1,12 @@ +import { markRaw } from 'vue'; +import * as os from '@/os'; import { onScrollTop, isTopVisible } from './scroll'; const SECOND_FETCH_LIMIT = 30; export default (opts) => ({ + emits: ['queue'], + data() { return { items: [], @@ -14,13 +18,6 @@ export default (opts) => ({ more: false, backed: false, // 遡り中か否か isBackTop: false, - ilObserver: new IntersectionObserver( - (entries) => entries.some((entry) => entry.isIntersecting) - && !this.moreFetching - && !this.fetching - && this.fetchMore() - ), - loadMoreElement: null as Element, }; }, @@ -35,41 +32,33 @@ export default (opts) => ({ }, watch: { - pagination() { - this.init(); + pagination: { + handler() { + this.init(); + }, + deep: true }, - queue() { - this.$emit('queue', this.queue.length); + queue: { + handler(a, b) { + if (a.length === 0 && b.length === 0) return; + this.$emit('queue', this.queue.length); + }, + deep: true } }, created() { opts.displayLimit = opts.displayLimit || 30; this.init(); - - this.$on('hook:activated', () => { - this.isBackTop = false; - }); - - this.$on('hook:deactivated', () => { - this.isBackTop = window.scrollY === 0; - }); }, - mounted() { - this.$nextTick(() => { - if (this.$refs.loadMore) { - this.loadMoreElement = this.$refs.loadMore instanceof Element ? this.$refs.loadMore : this.$refs.loadMore.$el; - if (this.$store.state.device.enableInfiniteScroll) this.ilObserver.observe(this.loadMoreElement); - this.loadMoreElement.addEventListener('click', this.fetchMore); - } - }); + activated() { + this.isBackTop = false; }, - beforeDestroy() { - this.ilObserver.disconnect(); - if (this.$refs.loadMore) this.loadMoreElement.removeEventListener('click', this.fetchMore); + deactivated() { + this.isBackTop = window.scrollY === 0; }, methods: { @@ -78,19 +67,30 @@ export default (opts) => ({ this.init(); }, + replaceItem(finder, data) { + const i = this.items.findIndex(finder); + this.items[i] = data; + }, + + removeItem(finder) { + const i = this.items.findIndex(finder); + this.items.splice(i, 1); + }, + async init() { this.queue = []; this.fetching = true; if (opts.before) opts.before(this); let params = typeof this.pagination.params === 'function' ? this.pagination.params(true) : this.pagination.params; if (params && params.then) params = await params; + if (params === null) return; const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint; - await this.$root.api(endpoint, { + await os.api(endpoint, { ...params, limit: this.pagination.noPaging ? (this.pagination.limit || 10) : (this.pagination.limit || 10) + 1, }).then(items => { for (const item of items) { - Object.freeze(item); + markRaw(item); } if (!this.pagination.noPaging && (items.length > (this.pagination.limit || 10))) { items.pop(); @@ -111,13 +111,13 @@ export default (opts) => ({ }, async fetchMore() { - if (!this.more || this.moreFetching || this.items.length === 0) return; + if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return; this.moreFetching = true; this.backed = true; let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params; if (params && params.then) params = await params; const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint; - await this.$root.api(endpoint, { + await os.api(endpoint, { ...params, limit: SECOND_FETCH_LIMIT + 1, ...(this.pagination.offsetMode ? { @@ -129,7 +129,7 @@ export default (opts) => ({ }), }).then(items => { for (const item of items) { - Object.freeze(item); + markRaw(item); } if (items.length > SECOND_FETCH_LIMIT) { items.pop(); @@ -172,9 +172,5 @@ export default (opts) => ({ append(item) { this.items.push(item); }, - - remove(find) { - this.items = this.items.filter(x => !find(x)); - }, } }); diff --git a/src/client/scripts/please-login.ts b/src/client/scripts/please-login.ts index ebd7dd82ab..a221665295 100644 --- a/src/client/scripts/please-login.ts +++ b/src/client/scripts/please-login.ts @@ -1,10 +1,14 @@ -export default ($root: any) => { - if ($root.$store.getters.isSignedIn) return; +import { i18n } from '@/i18n'; +import { dialog } from '@/os'; +import { store } from '@/store'; - $root.dialog({ - title: $root.$t('signinRequired'), +export function pleaseLogin() { + if (store.getters.isSignedIn) return; + + dialog({ + title: i18n.global.t('signinRequired'), text: null }); throw new Error('signin required'); -}; +} diff --git a/src/client/scripts/popout.ts b/src/client/scripts/popout.ts new file mode 100644 index 0000000000..f3611390c6 --- /dev/null +++ b/src/client/scripts/popout.ts @@ -0,0 +1,22 @@ +import * as config from '@/config'; + +export function popout(path: string, w?: HTMLElement) { + let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path; + url += '?zen'; // TODO: ちゃんとURLパースしてクエリ付ける + if (w) { + const position = w.getBoundingClientRect(); + const width = parseInt(getComputedStyle(w, '').width, 10); + const height = parseInt(getComputedStyle(w, '').height, 10); + const x = window.screenX + position.left; + const y = window.screenY + position.top; + window.open(url, url, + `width=${width}, height=${height}, top=${y}, left=${x}`); + } else { + const width = 400; + const height = 450; + const x = window.top.outerHeight / 2 + window.top.screenY - (height / 2); + const y = window.top.outerWidth / 2 + window.top.screenX - (width / 2); + window.open(url, url, + `width=${width}, height=${height}, top=${x}, left=${y}`); + } +} diff --git a/src/client/scripts/search.ts b/src/client/scripts/search.ts index 16057dfd34..45cc691fe4 100644 --- a/src/client/scripts/search.ts +++ b/src/client/scripts/search.ts @@ -1,15 +1,29 @@ import { faHistory } from '@fortawesome/free-solid-svg-icons'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { router } from '@/router'; + +export async function search(q?: string | null | undefined) { + if (q == null) { + const { canceled, result: query } = await os.dialog({ + title: i18n.global.t('search'), + input: true + }); + + if (canceled || query == null || query === '') return; + + q = query; + } -export async function search(v: any, q: string) { q = q.trim(); if (q.startsWith('@') && !q.includes(' ')) { - v.$router.push(`/${q}`); + router.push(`/${q}`); return; } if (q.startsWith('#')) { - v.$router.push(`/tags/${encodeURIComponent(q.substr(1))}`); + router.push(`/tags/${encodeURIComponent(q.substr(1))}`); return; } @@ -26,7 +40,7 @@ export async function search(v: any, q: string) { } v.$root.$emit('warp', date); - v.$root.dialog({ + os.dialog({ icon: faHistory, iconOnly: true, autoClose: true }); @@ -34,31 +48,31 @@ export async function search(v: any, q: string) { } if (q.startsWith('https://')) { - const dialog = v.$root.dialog({ + const dialog = os.dialog({ type: 'waiting', - text: v.$t('fetchingAsApObject') + '...', + text: i18n.global.t('fetchingAsApObject') + '...', showOkButton: false, showCancelButton: false, cancelableByBgClick: false }); try { - const res = await v.$root.api('ap/show', { + const res = await os.api('ap/show', { uri: q }); - dialog.close(); + dialog.cancel(); if (res.type === 'User') { - v.$router.push(`/@${res.object.username}@${res.object.host}`); + router.push(`/@${res.object.username}@${res.object.host}`); } else if (res.type === 'Note') { - v.$router.push(`/notes/${res.object.id}`); + router.push(`/notes/${res.object.id}`); } } catch (e) { - dialog.close(); + dialog.cancel(); // TODO: Show error } return; } - v.$router.push(`/search?q=${encodeURIComponent(q)}`); + router.push(`/search?q=${encodeURIComponent(q)}`); } diff --git a/src/client/scripts/select-drive-file.ts b/src/client/scripts/select-drive-file.ts deleted file mode 100644 index 3a4ac70007..0000000000 --- a/src/client/scripts/select-drive-file.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function selectDriveFile($root: any, multiple) { - return new Promise((res, rej) => { - import('../components/drive-window.vue').then(m => m.default).then(dialog => { - const w = $root.new(dialog, { - type: 'file', - multiple - }); - w.$once('selected', files => { - res(multiple ? files : files[0]); - }); - }); - }); -} diff --git a/src/client/scripts/select-drive-folder.ts b/src/client/scripts/select-drive-folder.ts deleted file mode 100644 index 313d552e3a..0000000000 --- a/src/client/scripts/select-drive-folder.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function selectDriveFolder($root: any, multiple) { - return new Promise((res, rej) => { - import('../components/drive-window.vue').then(m => m.default).then(dialog => { - const w = $root.new(dialog, { - type: 'folder', - multiple - }); - w.$once('selected', folders => { - res(multiple ? folders : (folders.length === 0 ? null : folders[0])); - }); - }); - }); -} diff --git a/src/client/scripts/select-file.ts b/src/client/scripts/select-file.ts index 462bdae9c0..80f9d25a2e 100644 --- a/src/client/scripts/select-file.ts +++ b/src/client/scripts/select-file.ts @@ -1,45 +1,23 @@ -import { faUpload, faCloud } from '@fortawesome/free-solid-svg-icons'; -import { selectDriveFile } from './select-drive-file'; -import { apiUrl } from '../config'; +import { faUpload, faCloud, faLink } from '@fortawesome/free-solid-svg-icons'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; -export function selectFile(component: any, src: any, label: string | null, multiple = false) { +export function selectFile(src: any, label: string | null, multiple = false) { return new Promise((res, rej) => { const chooseFileFromPc = () => { const input = document.createElement('input'); input.type = 'file'; input.multiple = multiple; input.onchange = () => { - const dialog = component.$root.dialog({ - type: 'waiting', - text: component.$t('uploading') + '...', - showOkButton: false, - showCancelButton: false, - cancelableByBgClick: false - }); - - const promises = Array.from(input.files).map(file => new Promise((ok, err) => { - const data = new FormData(); - data.append('file', file); - data.append('i', component.$store.state.i.token); - - fetch(apiUrl + '/drive/files/create', { - method: 'POST', - body: data - }) - .then(response => response.json()) - .then(ok) - .catch(err); - })); + const promises = Array.from(input.files).map(file => os.upload(file)); Promise.all(promises).then(driveFiles => { res(multiple ? driveFiles : driveFiles[0]); }).catch(e => { - component.$root.dialog({ + os.dialog({ type: 'error', text: e }); - }).finally(() => { - dialog.close(); }); // 一応廃棄 @@ -54,34 +32,57 @@ export function selectFile(component: any, src: any, label: string | null, multi }; const chooseFileFromDrive = () => { - selectDriveFile(component.$root, multiple).then(files => { + os.selectDriveFile(multiple).then(files => { res(files); }); }; - // TODO const chooseFileFromUrl = () => { + os.dialog({ + title: i18n.global.t('uploadFromUrl'), + input: { + placeholder: i18n.global.t('uploadFromUrlDescription') + } + }).then(({ canceled, result: url }) => { + if (canceled) return; + + const marker = Math.random().toString(); // TODO: UUIDとか使う + + const connection = os.stream.useSharedConnection('main'); + connection.on('urlUploadFinished', data => { + if (data.marker === marker) { + res(multiple ? [data.file] : data.file); + connection.dispose(); + } + }); + + os.api('drive/files/upload_from_url', { + url: url, + marker + }); + os.dialog({ + title: i18n.global.t('uploadFromUrlRequested'), + text: i18n.global.t('uploadFromUrlMayTakeTime') + }); + }); }; - component.$root.menu({ - items: [label ? { - text: label, - type: 'label' - } : undefined, { - text: component.$t('upload'), - icon: faUpload, - action: chooseFileFromPc - }, { - text: component.$t('fromDrive'), - icon: faCloud, - action: chooseFileFromDrive - }, /*{ - text: component.$t('fromUrl'), - icon: faLink, - action: chooseFileFromUrl - }*/], - source: src - }); + os.modalMenu([label ? { + text: label, + type: 'label' + } : undefined, { + text: i18n.global.t('upload'), + icon: faUpload, + action: chooseFileFromPc + }, { + text: i18n.global.t('fromDrive'), + icon: faCloud, + action: chooseFileFromDrive + }, { + text: i18n.global.t('fromUrl'), + icon: faLink, + action: chooseFileFromUrl + }], src); }); } diff --git a/src/client/scripts/set-i18n-contexts.ts b/src/client/scripts/set-i18n-contexts.ts index 872153e0bd..6014957361 100644 --- a/src/client/scripts/set-i18n-contexts.ts +++ b/src/client/scripts/set-i18n-contexts.ts @@ -1,8 +1,7 @@ -import VueI18n from 'vue-i18n'; import { clientDb, clear, bulkSet } from '../db'; import { deepEntries, delimitEntry } from 'deep-entries'; -export function setI18nContexts(lang: string, version: string, i18n: VueI18n, cleardb = false) { +export function setI18nContexts(lang: string, version: string, cleardb = false) { return Promise.all([ cleardb ? clear(clientDb.i18n) : Promise.resolve(), fetch(`/assets/locales/${lang}.${version}.json`) @@ -11,7 +10,6 @@ export function setI18nContexts(lang: string, version: string, i18n: VueI18n, cl .then(locale => { const flatLocaleEntries = deepEntries(locale, delimitEntry) as [string, string][]; bulkSet(flatLocaleEntries, clientDb.i18n); - i18n.locale = lang; - i18n.setLocaleMessage(lang, Object.fromEntries(flatLocaleEntries)); + return Object.fromEntries(flatLocaleEntries); }); } diff --git a/src/client/scripts/stream.ts b/src/client/scripts/stream.ts index defb22af8e..789bf94320 100644 --- a/src/client/scripts/stream.ts +++ b/src/client/scripts/stream.ts @@ -1,8 +1,7 @@ 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 { query as urlQuery } from '../../prelude/url'; /** @@ -10,18 +9,13 @@ import { query as urlQuery } from '../../prelude/url'; */ export default class Stream extends EventEmitter { private stream: ReconnectingWebsocket; - public state: 'initializing' | 'reconnecting' | 'connected'; + public state: 'initializing' | 'reconnecting' | 'connected' = 'initializing'; private sharedConnectionPools: Pool[] = []; private sharedConnections: SharedConnection[] = []; private nonSharedConnections: NonSharedConnection[] = []; - constructor(os: MiOS) { - super(); - - this.state = 'initializing'; - - const user = os.store.state.i; - + @autobind + public init(user): void { const query = urlQuery({ i: user?.token, _t: Date.now(), diff --git a/src/client/scripts/theme-editor.ts b/src/client/scripts/theme-editor.ts index e0c3bc25bc..3d69d2836a 100644 --- a/src/client/scripts/theme-editor.ts +++ b/src/client/scripts/theme-editor.ts @@ -5,11 +5,12 @@ import { themeProps, Theme } from './theme'; export type Default = null; export type Color = string; export type FuncName = 'alpha' | 'darken' | 'lighten'; -export type Func = { type: 'func', name: FuncName, arg: number, value: string }; -export type RefProp = { type: 'refProp', key: string }; -export type RefConst = { type: 'refConst', key: string }; +export type Func = { type: 'func'; name: FuncName; arg: number; value: string; }; +export type RefProp = { type: 'refProp'; key: string; }; +export type RefConst = { type: 'refConst'; key: string; }; +export type Css = { type: 'css'; value: string; }; -export type ThemeValue = Color | Func | RefProp | RefConst | Default; +export type ThemeValue = Color | Func | RefProp | RefConst | Css | Default; export type ThemeViewModel = [ string, ThemeValue ][]; @@ -31,17 +32,23 @@ export const fromThemeString = (str?: string) : ThemeValue => { type: 'refConst', key: str.slice(1), }; + } else if (str.startsWith('"')) { + return { + type: 'css', + value: str.substr(1).trim(), + }; } else { return str; } }; -export const toThemeString = (value: Color | Func | RefProp | RefConst) => { +export const toThemeString = (value: Color | Func | RefProp | RefConst | Css) => { if (typeof value === 'string') return value; switch (value.type) { case 'func': return `:${value.name}<${value.arg}<@${value.value}`; case 'refProp': return `@${value.key}`; case 'refConst': return `$${value.key}`; + case 'css': return `" ${value.value}`; } }; diff --git a/src/client/scripts/theme.ts b/src/client/scripts/theme.ts index 30eaf77e01..476a41ace5 100644 --- a/src/client/scripts/theme.ts +++ b/src/client/scripts/theme.ts @@ -101,7 +101,7 @@ function compile(theme: Theme): Record<string, string> { for (const [k, v] of Object.entries(theme.props)) { if (k.startsWith('$')) continue; // ignore const - props[k] = genValue(getColor(v)); + props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v)); } return props; diff --git a/src/client/sidebar.ts b/src/client/sidebar.ts new file mode 100644 index 0000000000..b8a2b8a7c3 --- /dev/null +++ b/src/client/sidebar.ts @@ -0,0 +1,139 @@ +import { faBell, faComments, faEnvelope } from '@fortawesome/free-regular-svg-icons'; +import { faAt, faBroadcastTower, faCloud, faColumns, faDoorClosed, faFileAlt, faFireAlt, faGamepad, faHashtag, faListUl, faSatellite, faSatelliteDish, faSearch, faStar, faTerminal, faUserClock, faUsers } from '@fortawesome/free-solid-svg-icons'; +import { computed, defineAsyncComponent } from 'vue'; +import { store } from '@/store'; +import { deckmode } from '@/config'; +import { search } from '@/scripts/search'; +import { popout } from '@/scripts/popout'; +import { router } from '@/router'; +import * as os from '@/os'; + +export const sidebarDef = { + notifications: { + title: 'notifications', + icon: faBell, + show: computed(() => store.getters.isSignedIn), + indicated: computed(() => store.getters.isSignedIn && store.state.i.hasUnreadNotification), + to: '/my/notifications', + }, + messaging: { + title: 'messaging', + icon: faComments, + show: computed(() => store.getters.isSignedIn), + indicated: computed(() => store.getters.isSignedIn && store.state.i.hasUnreadMessagingMessage), + action: () => { + switch (store.state.device.chatOpenBehavior) { + case 'window': { os.pageWindow('/my/messaging', defineAsyncComponent(() => import('@/pages/messaging/index.vue'))); break; } + case 'popout': { popout('/my/messaging'); break; } + default: { router.push('/my/messaging'); break; } + } + } + }, + drive: { + title: 'drive', + icon: faCloud, + show: computed(() => store.getters.isSignedIn), + to: '/my/drive', + }, + followRequests: { + title: 'followRequests', + icon: faUserClock, + show: computed(() => store.getters.isSignedIn && store.state.i.isLocked), + indicated: computed(() => store.getters.isSignedIn && store.state.i.hasPendingReceivedFollowRequest), + to: '/my/follow-requests', + }, + featured: { + title: 'featured', + icon: faFireAlt, + to: '/featured', + }, + explore: { + title: 'explore', + icon: faHashtag, + to: '/explore', + }, + announcements: { + title: 'announcements', + icon: faBroadcastTower, + indicated: computed(() => store.getters.isSignedIn && store.state.i.hasUnreadAnnouncement), + to: '/announcements', + }, + search: { + title: 'search', + icon: faSearch, + action: () => search(), + }, + lists: { + title: 'lists', + icon: faListUl, + show: computed(() => store.getters.isSignedIn), + to: '/my/lists', + }, + groups: { + title: 'groups', + icon: faUsers, + show: computed(() => store.getters.isSignedIn), + to: '/my/groups', + }, + antennas: { + title: 'antennas', + icon: faSatellite, + show: computed(() => store.getters.isSignedIn), + to: '/my/antennas', + }, + mentions: { + title: 'mentions', + icon: faAt, + show: computed(() => store.getters.isSignedIn), + indicated: computed(() => store.getters.isSignedIn && store.state.i.hasUnreadMentions), + to: '/my/mentions', + }, + messages: { + title: 'directNotes', + icon: faEnvelope, + show: computed(() => store.getters.isSignedIn), + indicated: computed(() => store.getters.isSignedIn && store.state.i.hasUnreadSpecifiedNotes), + to: '/my/messages', + }, + favorites: { + title: 'favorites', + icon: faStar, + show: computed(() => store.getters.isSignedIn), + to: '/my/favorites', + }, + pages: { + title: 'pages', + icon: faFileAlt, + show: computed(() => store.getters.isSignedIn), + to: '/my/pages', + }, + channels: { + title: 'channel', + icon: faSatelliteDish, + to: '/channels', + }, + games: { + title: 'games', + icon: faGamepad, + to: '/games', + }, + scratchpad: { + title: 'scratchpad', + icon: faTerminal, + to: '/scratchpad', + }, + rooms: { + title: 'rooms', + icon: faDoorClosed, + show: computed(() => store.getters.isSignedIn), + to: computed(() => `/@${store.state.i.username}/room`), + }, + deck: { + title: deckmode ? 'undeck' : 'deck', + icon: faColumns, + action: () => { + localStorage.setItem('deckmode', (!deckmode).toString()); + location.reload(); + }, + }, +}; diff --git a/src/client/store.ts b/src/client/store.ts index 07566954a2..fd469a4be3 100644 --- a/src/client/store.ts +++ b/src/client/store.ts @@ -1,10 +1,7 @@ -import Vuex from 'vuex'; +import { createStore } from 'vuex'; import createPersistedState from 'vuex-persistedstate'; import * as nestedProperty from 'nested-property'; -import { faSatelliteDish, faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons'; -import { faBell, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons'; -import { AiScript, utils, values } from '@syuilo/aiscript'; -import { apiUrl, deckmode } from './config'; +import { api } from '@/os'; import { erase } from '../prelude/array'; export const defaultSettings = { @@ -72,10 +69,10 @@ export const defaultDeviceSettings = { animation: true, animatedMfm: true, imageNewTab: false, + chatOpenBehavior: 'page', showFixedPostForm: false, - disablePagesScript: true, + disablePagesScript: false, enableInfiniteScroll: true, - fixedWidgetsPosition: false, useBlurEffectForModal: true, sidebarDisplay: 'full', // full, icon, hide roomGraphicsQuality: 'medium', @@ -98,152 +95,25 @@ function copy<T>(data: T): T { return JSON.parse(JSON.stringify(data)); } -export default () => new Vuex.Store({ +export const postFormActions = []; +export const userActions = []; +export const noteActions = []; +export const noteViewInterruptors = []; +export const notePostInterruptors = []; + +export const store = createStore({ + strict: _DEV_, + plugins: [createPersistedState({ paths: ['i', 'device', 'deviceUser', 'settings', 'instance'] })], state: { i: null, - pendingApiRequestsCount: 0, - spinner: null, - fullView: false, - - // Plugin - pluginContexts: new Map<string, AiScript>(), - postFormActions: [], - userActions: [], - noteActions: [], - noteViewInterruptors: [], - notePostInterruptors: [], }, getters: { isSignedIn: state => state.i != null, - - nav: (state, getters) => actions => ({ - notifications: { - title: 'notifications', - icon: faBell, - get show() { return getters.isSignedIn; }, - get indicated() { return getters.isSignedIn && state.i.hasUnreadNotification; }, - to: '/my/notifications', - }, - messaging: { - title: 'messaging', - icon: faComments, - get show() { return getters.isSignedIn; }, - get indicated() { return getters.isSignedIn && state.i.hasUnreadMessagingMessage; }, - to: '/my/messaging', - }, - drive: { - title: 'drive', - icon: faCloud, - get show() { return getters.isSignedIn; }, - to: '/my/drive', - }, - followRequests: { - title: 'followRequests', - icon: faUserClock, - get show() { return getters.isSignedIn && state.i.isLocked; }, - get indicated() { return getters.isSignedIn && state.i.hasPendingReceivedFollowRequest; }, - to: '/my/follow-requests', - }, - featured: { - title: 'featured', - icon: faFireAlt, - to: '/featured', - }, - explore: { - title: 'explore', - icon: faHashtag, - to: '/explore', - }, - announcements: { - title: 'announcements', - icon: faBroadcastTower, - get indicated() { return getters.isSignedIn && state.i.hasUnreadAnnouncement; }, - to: '/announcements', - }, - search: { - title: 'search', - icon: faSearch, - action: () => actions.search(), - }, - lists: { - title: 'lists', - icon: faListUl, - get show() { return getters.isSignedIn; }, - to: '/my/lists', - }, - groups: { - title: 'groups', - icon: faUsers, - get show() { return getters.isSignedIn; }, - to: '/my/groups', - }, - antennas: { - title: 'antennas', - icon: faSatellite, - get show() { return getters.isSignedIn; }, - to: '/my/antennas', - }, - mentions: { - title: 'mentions', - icon: faAt, - get show() { return getters.isSignedIn; }, - get indicated() { return getters.isSignedIn && state.i.hasUnreadMentions; }, - to: '/my/mentions', - }, - messages: { - title: 'directNotes', - icon: faEnvelope, - get show() { return getters.isSignedIn; }, - get indicated() { return getters.isSignedIn && state.i.hasUnreadSpecifiedNotes; }, - to: '/my/messages', - }, - favorites: { - title: 'favorites', - icon: faStar, - get show() { return getters.isSignedIn; }, - to: '/my/favorites', - }, - pages: { - title: 'pages', - icon: faFileAlt, - get show() { return getters.isSignedIn; }, - to: '/my/pages', - }, - channels: { - title: 'channel', - icon: faSatelliteDish, - to: '/channels', - }, - games: { - title: 'games', - icon: faGamepad, - to: '/games', - }, - scratchpad: { - title: 'scratchpad', - icon: faTerminal, - to: '/scratchpad', - }, - rooms: { - title: 'rooms', - icon: faDoorClosed, - get show() { return getters.isSignedIn; }, - get to() { return `/@${state.i.username}/room`; }, - }, - deck: { - title: deckmode ? 'undeck' : 'deck', - icon: faColumns, - action: () => { - localStorage.setItem('deckmode', (!deckmode).toString()); - location.reload(); - }, - }, - }), }, mutations: { @@ -254,56 +124,6 @@ export default () => new Vuex.Store({ updateIKeyValue(state, { key, value }) { state.i[key] = value; }, - - setFullView(state, v) { - state.fullView = v; - }, - - initPlugin(state, { plugin, aiscript }) { - state.pluginContexts.set(plugin.id, aiscript); - }, - - registerPostFormAction(state, { pluginId, title, handler }) { - state.postFormActions.push({ - title, handler: (form, update) => { - state.pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => { - update(key.value, value.value); - })]); - } - }); - }, - - registerUserAction(state, { pluginId, title, handler }) { - state.userActions.push({ - title, handler: (user) => { - state.pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(user)]); - } - }); - }, - - registerNoteAction(state, { pluginId, title, handler }) { - state.noteActions.push({ - title, handler: (note) => { - state.pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]); - } - }); - }, - - registerNoteViewInterruptor(state, { pluginId, handler }) { - state.noteViewInterruptors.push({ - handler: (note) => { - return state.pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]); - } - }); - }, - - registerNotePostInterruptor(state, { pluginId, handler }) { - state.notePostInterruptors.push({ - handler: (note) => { - return state.pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]); - } - }); - }, }, actions: { @@ -349,47 +169,6 @@ export default () => new Vuex.Store({ ctx.commit('settings/init', me.clientData); } }, - - api(ctx, { endpoint, data, token }) { - if (++ctx.state.pendingApiRequestsCount === 1) { - // TODO: spinnerの表示はstoreでやらない - ctx.state.spinner = document.createElement('div'); - ctx.state.spinner.setAttribute('id', 'wait'); - document.body.appendChild(ctx.state.spinner); - } - - const onFinally = () => { - if (--ctx.state.pendingApiRequestsCount === 0) ctx.state.spinner.parentNode.removeChild(ctx.state.spinner); - }; - - const promise = new Promise((resolve, reject) => { - // Append a credential - if (ctx.getters.isSignedIn) (data as any).i = ctx.state.i.token; - if (token !== undefined) (data as any).i = token; - - // Send request - fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, { - method: 'POST', - body: JSON.stringify(data), - credentials: 'omit', - cache: 'no-cache' - }).then(async (res) => { - const body = res.status === 204 ? null : await res.json(); - - if (res.status === 200) { - resolve(body); - } else if (res.status === 204) { - resolve(); - } else { - reject(body.error); - } - }).catch(reject); - }); - - promise.then(onFinally, onFinally); - - return promise; - } }, modules: { @@ -408,12 +187,9 @@ export default () => new Vuex.Store({ actions: { async fetch(ctx) { - const meta = await ctx.dispatch('api', { - endpoint: 'meta', - data: { - detail: false - } - }, { root: true }); + const meta = await api('meta', { + detail: false + }); ctx.commit('set', meta); } @@ -676,13 +452,10 @@ export default () => new Vuex.Store({ ctx.commit('set', x); if (ctx.rootGetters.isSignedIn) { - ctx.dispatch('api', { - endpoint: 'i/update-client-setting', - data: { - name: x.key, - value: x.value - } - }, { root: true }); + api('i/update-client-setting', { + name: x.key, + value: x.value + }); } }, } diff --git a/src/client/style.scss b/src/client/style.scss index 4e0baf63cf..1bc6c90483 100644 --- a/src/client/style.scss +++ b/src/client/style.scss @@ -1,6 +1,7 @@ @charset "utf-8"; :root { + --baseContentWidth: 750px; --radius: 8px; --marginFull: 16px; --marginHalf: 10px; @@ -12,10 +13,10 @@ } } -* { - tap-highlight-color: transparent; - -webkit-tap-highlight-color: transparent; -} +::selection { + color: #fff; + background-color: var(--accent); +} html { touch-action: manipulation; @@ -83,30 +84,6 @@ 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; - color: var(--accent); - fill: currentColor; - } -} - html, body { margin: 0; padding: 0; @@ -119,12 +96,19 @@ a { text-decoration: none; cursor: pointer; color: inherit; + tap-highlight-color: transparent; + -webkit-tap-highlight-color: transparent; &:hover { text-decoration: underline; } } +textarea, input { + tap-highlight-color: transparent; + -webkit-tap-highlight-color: transparent; +} + hr { margin: var(--margin) 0 var(--margin) 0; border: none; @@ -132,54 +116,6 @@ hr { background: var(--divider); } -#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; - } -} - ._noSelect { user-select: none; -webkit-user-select: none; @@ -216,6 +152,8 @@ hr { cursor: pointer; color: var(--fg); touch-action: manipulation; + tap-highlight-color: transparent; + -webkit-tap-highlight-color: transparent; font-size: 1em; &, * { @@ -286,46 +224,15 @@ hr { } ._panel { - position: relative; - z-index: 1; + //position: relative; + //z-index: 1; background: var(--panel); border-radius: var(--radius); - box-shadow: 0 0 0 1px var(--panelBorder); + //border: var(--panelBorder); + box-shadow: var(--panelShadow); overflow: hidden; } -._close_ ._list_ > * { - box-shadow: 0 1px 0 0 var(--divider), 0 -1px 0 0 var(--divider); - border-radius: 0; - margin: 0 !important; -} - -.__panelButton { - display: flex; - width: 100%; - min-height: 48px; - align-items: center; - justify-content: center; -} - -._panel._button { - @extend .__panelButton; -} - -._panel._buttonPrimary { - @extend .__panelButton; - color: var(--accent); - background: var(--panel); - - &:not(:disabled):hover { - background: var(--panel); - } - - &:not(:disabled):active { - background: var(--panel); - } -} - ._card { @extend ._panel; @@ -370,6 +277,70 @@ hr { } } +._close_ ._list_ > * { + border: none; + border-bottom: solid 1px var(--divider); + border-radius: 0; + box-shadow: none; + margin: 0 !important; +} + +._loadMore { + @extend ._panel; + @extend ._button; + width: 100%; + padding: 12px 0; +} + +._popup { + background: var(--panel); + border-radius: var(--radius); +} + +._section { + padding: var(--section-padding, 32px); + + &:empty { + display: none; + } + + &:not(:empty) + ._section { + border-top: solid 1px var(--divider); + } + + @media (max-width: 500px) { + padding: var(--section-padding, 10px); + + > ._title { + font-size: 1.1em; + font-weight: bold; + } + } + + > ._title, + > ._content { + max-width: var(--baseContentWidth); + margin: 0 auto; + } + + > ._title { + margin-bottom: 24px; + font-size: 1.25em; + font-weight: bold; + } + + &._fitBottom { + padding-bottom: 0; + } +} + +._narrow_ ._section { + > ._title { + padding: 8px; + font-size: 1em; + } +} + ._narrow_ ._card { > ._title { padding: 16px; @@ -385,6 +356,12 @@ hr { } } +._acrylic { + background: var(--acrylicPanel); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); +} + ._vMargin { & + ._vMargin { margin-top: var(--margin); @@ -464,7 +441,7 @@ hr { .zoom-enter-active, .zoom-leave-active { transition: opacity 0.5s, transform 0.5s !important; } -.zoom-enter, .zoom-leave-to { +.zoom-enter-from, .zoom-leave-to { opacity: 0; transform: scale(0.9); } @@ -476,36 +453,24 @@ hr { 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-enter-from, .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); - } +@keyframes blink { + 0% { opacity: 1; transform: scale(1); } + 30% { opacity: 1; transform: scale(1); } + 90% { opacity: 0; transform: scale(0.5); } } -@keyframes spin { +@keyframes anime-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } -@keyframes jump { +@keyframes anime-jump { 0% { transform: translateY(0); } 25% { transform: translateY(-16px); } 50% { transform: translateY(0); } @@ -513,8 +478,60 @@ hr { 100% { transform: translateY(0); } } -@keyframes blink { - 0% { opacity: 1; transform: scale(1); } - 30% { opacity: 1; transform: scale(1); } - 90% { opacity: 0; transform: scale(0.5); } +@keyframes anime-tada { + from { + transform: scale3d(1, 1, 1); + } + + 10%, + 20% { + transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); + } + + 30%, + 50%, + 70%, + 90% { + transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); + } + + 40%, + 60%, + 80% { + transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); + } + + to { + transform: scale3d(1, 1, 1); + } +} + +@keyframes anime-rubberBand { + from { + transform: scale3d(1, 1, 1); + } + + 30% { + transform: scale3d(1.25, 0.75, 1); + } + + 40% { + transform: scale3d(0.75, 1.25, 1); + } + + 50% { + transform: scale3d(1.15, 0.85, 1); + } + + 65% { + transform: scale3d(0.95, 1.05, 1); + } + + 75% { + transform: scale3d(1.05, 0.95, 1); + } + + to { + transform: scale3d(1, 1, 1); + } } diff --git a/src/client/sw.ts b/src/client/sw.ts index 341198852e..01ed216029 100644 --- a/src/client/sw.ts +++ b/src/client/sw.ts @@ -3,7 +3,7 @@ */ declare var self: ServiceWorkerGlobalScope; -import composeNotification from './scripts/compose-notification'; +import composeNotification from '@/scripts/compose-notification'; // eslint-disable-next-line no-undef const version = _VERSION_; diff --git a/src/client/themes/_dark.json5 b/src/client/themes/_dark.json5 index d401e807a9..ce06618aca 100644 --- a/src/client/themes/_dark.json5 +++ b/src/client/themes/_dark.json5 @@ -23,6 +23,8 @@ panelHeaderFg: '@fg', panelHeaderDivider: 'rgba(0, 0, 0, 0)', panelBorder: 'rgba(0, 0, 0, 0)', + panelShadow: '" 0 8px 24px rgba(0, 0, 0, 0.12)', + acrylicPanel: ':alpha<0.5<@panel', shadow: 'rgba(0, 0, 0, 0.3)', header: ':alpha<0.7<@bg', navBg: '@bg', @@ -46,8 +48,6 @@ 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', @@ -56,7 +56,6 @@ wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', badge: '#31b1ce', messageBg: ':lighten<5<@bg', - deckColumnBorder: ':lighten<10<@panel', htmlThemeColor: '@bg', X1: ':alpha<0<@bg', X2: ':darken<2<@panel', diff --git a/src/client/themes/_light.json5 b/src/client/themes/_light.json5 index 50aa0cd235..9862e8fdc6 100644 --- a/src/client/themes/_light.json5 +++ b/src/client/themes/_light.json5 @@ -13,7 +13,7 @@ accentDarken: ':darken<10<@accent', accentLighten: ':lighten<10<@accent', focus: ':alpha<0.3<@accent', - bg: '#fafafa', + bg: '#fff', fg: '#5c6a73', fgHighlighted: ':darken<3<@fg', divider: 'rgba(0, 0, 0, 0.1)', @@ -23,6 +23,8 @@ panelHeaderFg: '@fg', panelHeaderDivider: 'rgba(0, 0, 0, 0)', panelBorder: 'rgba(0, 0, 0, 0)', + panelShadow: '" 0 8px 24px rgb(21 43 75 / 8%)', + acrylicPanel: ':alpha<0.5<@panel', shadow: 'rgba(0, 0, 0, 0.1)', header: ':alpha<0.7<@bg', navBg: '@bg', @@ -46,8 +48,6 @@ 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', @@ -56,7 +56,6 @@ wallpaperOverlay: 'rgba(255, 255, 255, 0.5)', badge: '#31b1ce', messageBg: '@panel', - deckColumnBorder: ':darken<20<@panel', htmlThemeColor: '@bg', X1: ':alpha<0<@bg', X2: ':darken<2<@panel', diff --git a/src/client/themes/black.json5 b/src/client/themes/black.json5 index 579738f725..e00f308fd2 100644 --- a/src/client/themes/black.json5 +++ b/src/client/themes/black.json5 @@ -11,10 +11,9 @@ divider: '#2d2d2d', panelHeaderBg: '@panel', panelHeaderDivider: '@divider', - panelBorder: '@divider', + panelShadow: '" 0 0 0 1px var(--divider)', shadow: 'rgba(255, 255, 255, 0.05)', modalBg: 'rgba(255, 255, 255, 0.1)', messageBg: '#1d1d1d', - deckColumnBorder: '@divider', }, } diff --git a/src/client/themes/white.json5 b/src/client/themes/white.json5 index 4c3db53acd..4a5e3f23ef 100644 --- a/src/client/themes/white.json5 +++ b/src/client/themes/white.json5 @@ -8,11 +8,10 @@ base: 'light', props: { - bg: '#f2f2f2', + bg: '#F6F7F7', header: ':alpha<0.7<@bg', navBg: '@bg', panelHeaderDivider: '@divider', messageBg: '#dedede', - deckColumnBorder: '#cccccc', }, } diff --git a/src/client/tsconfig.json b/src/client/tsconfig.json index aac0d1bfe7..e6a6b8eb2d 100644 --- a/src/client/tsconfig.json +++ b/src/client/tsconfig.json @@ -1,35 +1,40 @@ { - "compilerOptions": { - "allowJs": true, - "noEmitOnError": false, - "noImplicitAny": false, - "noImplicitReturns": true, - "noUnusedParameters": false, - "noUnusedLocals": true, - "noFallthroughCasesInSwitch": true, - "declaration": false, - "sourceMap": false, - "target": "es2017", - "module": "esnext", - "moduleResolution": "node", - "removeComments": false, - "noLib": false, - "strict": true, - "strictNullChecks": false, - "experimentalDecorators": true, - "resolveJsonModule": true, - "typeRoots": [ - "node_modules/@types", - "src/@types" - ], - "lib": [ - "esnext", - "dom", - "webworker" - ] - }, - "compileOnSave": false, - "include": [ - "./**/*.ts" - ] + "compilerOptions": { + "allowJs": true, + "noEmitOnError": false, + "noImplicitAny": false, + "noImplicitReturns": true, + "noUnusedParameters": false, + "noUnusedLocals": true, + "noFallthroughCasesInSwitch": true, + "declaration": false, + "sourceMap": false, + "target": "es2017", + "module": "esnext", + "moduleResolution": "node", + "removeComments": false, + "noLib": false, + "strict": true, + "strictNullChecks": false, + "experimentalDecorators": true, + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + }, + "typeRoots": [ + "node_modules/@types", + "src/@types", + "src/client/@types" + ], + "lib": [ + "esnext", + "dom", + "webworker" + ] + }, + "compileOnSave": false, + "include": [ + "./**/*.ts" + ] } diff --git a/src/client/ui/_common_/header.vue b/src/client/ui/_common_/header.vue new file mode 100644 index 0000000000..32207d5dca --- /dev/null +++ b/src/client/ui/_common_/header.vue @@ -0,0 +1,149 @@ +<template> +<div class="fdidabkb"> + <transition :name="$store.state.device.animation ? 'header' : ''" mode="out-in" appear> + <button class="_button back" v-if="withBack && canBack" @click="back()"><Fa :icon="faChevronLeft"/></button> + </transition> + <template v-if="info"> + <div class="titleContainer"> + <div class="title" v-for="header in info.header" :key="header.id" :class="{ _button: header.onClick, selected: header.selected }" @click="header.onClick" v-tooltip="header.tooltip"> + <Fa v-if="header.icon" :icon="header.icon" :key="header.icon" class="icon"/> + <MkAvatar v-else-if="header.avatar" class="avatar" :user="header.avatar" :disable-preview="true"/> + <span v-if="header.title" class="text">{{ header.title }}</span> + <MkUserName v-else-if="header.userName" :user="header.userName" :nowrap="false" class="text"/> + </div> + </div> + <button class="_button action" v-if="info.action" @click="info.action.handler"><Fa :icon="info.action.icon" :key="info.action.icon"/></button> + </template> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faChevronLeft } from '@fortawesome/free-solid-svg-icons'; + +export default defineComponent({ + props: { + info: { + required: true + }, + withBack: { + type: Boolean, + required: false, + default: true, + }, + }, + + data() { + return { + canBack: false, + height: 0, + faChevronLeft + }; + }, + + watch: { + $route(to, from) { + this.canBack = (window.history.length > 0 && !['index'].includes(to.name)); + }, + }, + + mounted() { + this.height = this.$el.parentElement.offsetHeight + 'px'; + new ResizeObserver((entries, observer) => { + this.height = this.$el.parentElement.offsetHeight + 'px'; + }).observe(this.$el); + }, + + methods: { + back() { + if (this.canBack) this.$router.back(); + }, + } +}); +</script> + +<style lang="scss" scoped vars="{ height }"> +.fdidabkb { + text-align: center; + + > .back { + height: var(--height); + width: var(--height); + } + + > .action { + height: var(--height); + width: var(--height); + } + + > .titleContainer { + width: calc(100% - (var(--height) * 2)); + + > .title { + height: var(--height); + + > .avatar { + $size: 32px; + margin: calc((var(--height) - #{$size}) / 2) 8px calc((var(--height) - #{$size}) / 2) 0; + pointer-events: none; + } + } + } +} +</style> + +<style lang="scss" scoped> +.fdidabkb { + > .back { + position: absolute; + z-index: 1; + top: 0; + left: 0; + } + + > .action { + position: absolute; + z-index: 1; + top: 0; + right: 0; + } + + > .titleContainer { + margin: 0 auto; + overflow: auto; + white-space: nowrap; + + > .title { + display: inline-block; + vertical-align: bottom; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 16px; + + > .icon + .text { + margin-left: 8px; + } + + > .avatar { + $size: 32px; + display: inline-block; + width: $size; + height: $size; + vertical-align: bottom; + } + + &._button { + &:hover { + color: var(--fgHighlighted); + } + } + + &.selected { + box-shadow: 0 -2px 0 0 var(--accent) inset; + color: var(--fgHighlighted); + } + } + } +} +</style> diff --git a/src/client/deck.vue b/src/client/ui/deck.vue index c383a1b15c..b067b948ce 100644 --- a/src/client/deck.vue +++ b/src/client/ui/deck.vue @@ -1,59 +1,57 @@ <template> <div class="mk-deck" :class="`${$store.state.device.deckColumnAlign}`" v-hotkey.global="keymap"> - <x-sidebar ref="nav"/> + <XSidebar ref="nav"/> <!-- TODO: deckMainColumnPlace を見て位置変える --> <deck-column class="column" v-if="$store.state.device.deckAlwaysShowMainColumn || $route.name !== 'index'"> - <template #action> - <button class="_button back" v-if="canBack" @click="back()"><fa :icon="faChevronLeft"/></button> - </template> - <template #header> - <div class="iwnjqeul"> - <div class="default"> - <portal-target name="avatar" slim/> - <span class="title"><portal-target name="icon" slim/><portal-target name="title" slim/></span> - </div> - <div class="custom"> - <portal-target name="header" slim/> - </div> - </div> + <XHeader :info="pageInfo"/> </template> - <router-view></router-view> + <router-view v-slot="{ Component }"> + <transition> + <keep-alive :include="['timeline']"> + <component :is="Component" :ref="changePage"/> + </keep-alive> + </transition> + </router-view> </deck-column> <template v-for="ids in layout"> <div v-if="ids.length > 1" class="folder column"> - <deck-column-core v-for="id, i in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/> + <deck-column-core v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/> </div> <deck-column-core v-else class="column" :ref="ids[0]" :key="ids[0]" :column="columns.find(c => c.id === ids[0])" @parent-focus="moveFocus(ids[0], $event)"/> </template> - <button @click="addColumn" class="_button add"><fa :icon="faPlus"/></button> + <button @click="addColumn" class="_button add"><Fa :icon="faPlus"/></button> - <button v-if="$store.getters.isSignedIn" class="nav _button" @click="showNav()"><fa :icon="faBars"/><i v-if="navIndicated"><fa :icon="faCircle"/></i></button> - <button v-if="$store.getters.isSignedIn" class="post _buttonPrimary" @click="post()"><fa :icon="faPencilAlt"/></button> + <button v-if="$store.getters.isSignedIn" class="nav _button" @click="showNav()"><Fa :icon="faBars"/><i v-if="navIndicated"><Fa :icon="faCircle"/></i></button> + <button v-if="$store.getters.isSignedIn" class="post _buttonPrimary" @click="post()"><Fa :icon="faPencilAlt"/></button> - <stream-indicator v-if="$store.getters.isSignedIn"/> + <StreamIndicator v-if="$store.getters.isSignedIn"/> </div> </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; import { faPlus, faPencilAlt, faChevronLeft, faBars, faCircle } from '@fortawesome/free-solid-svg-icons'; import { } from '@fortawesome/free-regular-svg-icons'; import { v4 as uuid } from 'uuid'; -import { host } from './config'; -import { search } from './scripts/search'; -import DeckColumnCore from './components/deck/column-core.vue'; -import DeckColumn from './components/deck/column.vue'; -import XSidebar from './components/sidebar.vue'; -import { getScrollContainer } from './scripts/scroll'; +import { host } from '@/config'; +import { search } from '@/scripts/search'; +import DeckColumnCore from '@/components/deck/column-core.vue'; +import DeckColumn from '@/components/deck/column.vue'; +import XSidebar from '@/components/sidebar.vue'; +import XHeader from './_common_/header.vue'; +import { getScrollContainer } from '@/scripts/scroll'; +import * as os from '@/os'; +import { sidebarDef } from '@/sidebar'; -export default Vue.extend({ +export default defineComponent({ components: { XSidebar, + XHeader, DeckColumn, DeckColumnCore, }, @@ -61,13 +59,10 @@ export default Vue.extend({ data() { return { host: host, + pageInfo: null, pageKey: 0, - searching: false, connection: null, - searchQuery: '', - searchWait: false, - canBack: false, - menuDef: this.$store.getters.nav({}), + menuDef: sidebarDef, wallpaper: localStorage.getItem('wallpaper') != null, faPlus, faPencilAlt, faChevronLeft, faBars, faCircle }; @@ -103,7 +98,6 @@ export default Vue.extend({ watch: { $route(to, from) { this.pageKey++; - this.canBack = (window.history.length > 0 && !['index'].includes(to.name)); }, }, @@ -113,7 +107,7 @@ export default Vue.extend({ window.addEventListener('wheel', this.onWheel); if (this.$store.getters.isSignedIn) { - this.connection = this.$root.stream.useSharedConnection('main'); + this.connection = os.stream.useSharedConnection('main'); this.connection.on('notification', this.onNotification); } }, @@ -122,6 +116,13 @@ export default Vue.extend({ }, methods: { + changePage(page) { + if (page == null) return; + if (page.INFO) { + this.pageInfo = page.INFO; + } + }, + onWheel(e) { if (getScrollContainer(e.target) == null) { document.documentElement.scrollLeft += e.deltaY > 0 ? 96 : -96; @@ -136,28 +137,8 @@ export default Vue.extend({ this.$router.push('/docs/keyboard-shortcut'); }, - back() { - if (this.canBack) 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; - }); - }); + os.post(); }, async onNotification(notification) { @@ -166,15 +147,15 @@ export default Vue.extend({ } if (document.visibilityState === 'visible') { - this.$root.stream.send('readNotification', { + os.stream.send('readNotification', { id: notification.id }); - this.$root.new(await import('./components/toast.vue').then(m => m.default), { + os.popup(await import('@/components/toast.vue'), { notification - }); + }, {}, 'closed'); } - this.$root.sound('notification'); + os.sound('notification'); }, async addColumn(ev) { @@ -188,7 +169,7 @@ export default Vue.extend({ 'direct', ]; - const { canceled, result: column } = await this.$root.dialog({ + const { canceled, result: column } = await os.dialog({ title: this.$t('_deck.addColumn'), type: null, select: { @@ -292,34 +273,4 @@ export default Vue.extend({ } } } - -.iwnjqeul { - $header-height: 42px; // TODO: column.vueのそれを参照するようにしたい(出来るのか?) - - > .default { - > .avatar { - $size: 28px; - 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; - margin: 0; - line-height: $header-height; - - > [data-icon] { - margin-right: 8px; - } - } - } - - > .custom { - position: absolute; - top: 0; - } -} </style> diff --git a/src/client/ui/default.vue b/src/client/ui/default.vue new file mode 100644 index 0000000000..5eeee0a667 --- /dev/null +++ b/src/client/ui/default.vue @@ -0,0 +1,415 @@ +<template> +<div class="mk-app" v-hotkey.global="keymap"> + <XSidebar ref="nav" class="sidebar"/> + + <div class="contents" ref="contents" :class="{ wallpaper }"> + <header class="header" ref="header"> + <XHeader :info="pageInfo"/> + </header> + <main ref="main"> + <div class="content"> + <router-view v-slot="{ Component }"> + <transition :name="$store.state.device.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> + <keep-alive :include="['timeline']"> + <component :is="Component" :ref="changePage"/> + </keep-alive> + </transition> + </router-view> + </div> + <div class="spacer"></div> + </main> + </div> + + <div v-if="isDesktop" class="widgets"> + <div ref="widgetsSpacer"></div> + <XWidgets @mounted="attachSticky"/> + </div> + + <div class="buttons" :class="{ navHidden }"> + <button class="button nav _button" @click="showNav" ref="navButton"><Fa :icon="faBars"/><i v-if="navIndicated"><Fa :icon="faCircle"/></i></button> + <button v-if="$route.name === 'index'" class="button home _button" @click="top()"><Fa :icon="faHome"/></button> + <button v-else class="button home _button" @click="$router.push('/')"><Fa :icon="faHome"/></button> + <button class="button notifications _button" @click="$router.push('/my/notifications')"><Fa :icon="faBell"/><i v-if="$store.state.i.hasUnreadNotification"><Fa :icon="faCircle"/></i></button> + <button class="button widget _button" @click="widgetsShowing = true"><Fa :icon="faLayerGroup"/></button> + </div> + + <button class="widgetButton _button" :class="{ navHidden }" @click="widgetsShowing = true"><Fa :icon="faLayerGroup"/></button> + + <transition name="tray-back"> + <div class="tray-back _modalBg" + v-if="widgetsShowing" + @click="widgetsShowing = false" + @touchstart.passive="widgetsShowing = false" + ></div> + </transition> + + <transition name="tray"> + <XWidgets v-if="widgetsShowing" class="tray"/> + </transition> + + <StreamIndicator/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { faLayerGroup, faBars, faHome, faCircle } from '@fortawesome/free-solid-svg-icons'; +import { faBell } from '@fortawesome/free-regular-svg-icons'; +import { host } from '@/config'; +import { search } from '@/scripts/search'; +import { StickySidebar } from '@/scripts/sticky-sidebar'; +import XSidebar from '@/components/sidebar.vue'; +import XHeader from './_common_/header.vue'; +import * as os from '@/os'; +import { sidebarDef } from '@/sidebar'; + +const DESKTOP_THRESHOLD = 1100; + +export default defineComponent({ + components: { + XSidebar, + XHeader, + XWidgets: defineAsyncComponent(() => import('./default.widgets.vue')), + }, + + data() { + return { + host: host, + pageKey: 0, + pageInfo: null, + connection: null, + isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, + menuDef: sidebarDef, + navHidden: false, + widgetsShowing: false, + wallpaper: localStorage.getItem('wallpaper') != null, + faLayerGroup, faBars, faBell, faHome, faCircle, + }; + }, + + computed: { + keymap(): any { + return { + 'd': () => { + if (this.$store.state.device.syncDeviceDarkMode) return; + this.$store.commit('device/set', { key: 'darkMode', value: !this.$store.state.device.darkMode }); + }, + 'p': os.post, + 'n': os.post, + 's': search, + 'h|/': this.help + }; + }, + + widgets(): any { + return this.$store.state.deviceUser.widgets; + }, + + menu(): string[] { + return this.$store.state.deviceUser.menu; + }, + + navIndicated(): boolean { + for (const def in this.menuDef) { + if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから + if (this.menuDef[def].indicated) return true; + } + return false; + } + }, + + watch: { + $route(to, from) { + this.pageKey++; + }, + }, + + created() { + document.documentElement.style.overflowY = 'scroll'; + + this.connection = os.stream.useSharedConnection('main'); + this.connection.on('notification', this.onNotification); + + if (this.$store.state.deviceUser.widgets.length === 0) { + this.$store.commit('deviceUser/setWidgets', [{ + name: 'calendar', + id: 'a', place: 'right', data: {} + }, { + name: 'notifications', + id: 'b', place: 'right', data: {} + }, { + name: 'trends', + id: 'c', place: 'right', data: {} + }]); + } + }, + + mounted() { + this.adjustUI(); + + const ro = new ResizeObserver((entries, observer) => { + this.adjustUI(); + }); + + ro.observe(this.$refs.contents); + + window.addEventListener('resize', this.adjustUI, { passive: true }); + + if (!this.isDesktop) { + window.addEventListener('resize', () => { + if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true; + }, { passive: true }); + } + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page.INFO) { + this.pageInfo = page.INFO; + } + }, + + adjustUI() { + const navWidth = this.$refs.nav.$el.offsetWidth; + this.navHidden = navWidth === 0; + if (this.$refs.contents == null) return; + const width = this.$refs.contents.offsetWidth; + this.$refs.header.style.width = `${width}px`; + }, + + showNav() { + this.$refs.nav.show(); + }, + + attachSticky(el) { + const sticky = new StickySidebar(el, this.$refs.widgetsSpacer); + window.addEventListener('scroll', () => { + sticky.calc(window.scrollY); + }, { passive: true }); + }, + + top() { + window.scroll({ top: 0, behavior: 'smooth' }); + }, + + help() { + this.$router.push('/docs/keyboard-shortcut'); + }, + + onTransition() { + if (window._scroll) window._scroll(); + }, + + async onNotification(notification) { + if (this.$store.state.i.mutingNotificationTypes.includes(notification.type)) { + return; + } + if (document.visibilityState === 'visible') { + os.stream.send('readNotification', { + id: notification.id + }); + + os.popup(await import('@/components/toast.vue'), { + notification + }, {}, 'closed'); + } + + os.sound('notification'); + }, + } +}); +</script> + +<style lang="scss" scoped> +.tray-enter-active, +.tray-leave-active { + opacity: 1; + transform: translateX(0); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tray-enter-from, +.tray-leave-active { + opacity: 0; + transform: translateX(240px); +} + +.tray-back-enter-active, +.tray-back-leave-active { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tray-back-enter-from, +.tray-back-leave-active { + opacity: 0; +} + +.mk-app { + $header-height: 60px; + $ui-font-size: 1em; // TODO: どこかに集約したい + $widgets-hide-threshold: 1090px; + + // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + min-height: calc(var(--vh, 1vh) * 100); + box-sizing: border-box; + + display: flex; + + > .contents { + width: 100%; + min-width: 0; + padding-top: $header-height; + + &.wallpaper { + background: var(--wallpaperOverlay); + //backdrop-filter: blur(4px); + } + + > .header { + position: fixed; + z-index: 1000; + top: 0; + height: $header-height; + width: 100%; + line-height: $header-height; + text-align: center; + font-weight: bold; + //background-color: var(--panel); + -webkit-backdrop-filter: blur(32px); + backdrop-filter: blur(32px); + background-color: var(--header); + border-bottom: solid 1px var(--divider); + } + + > main { + min-width: 0; + + > .content { + > * { + // ほんとは単に calc(100vh - #{$header-height}) と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + min-height: calc((var(--vh, 1vh) * 100) - #{$header-height}); + } + } + + > .spacer { + height: 82px; + } + } + } + + > .widgets { + padding: 0 var(--margin); + border-left: solid 1px var(--divider); + + @media (max-width: $widgets-hide-threshold) { + display: none; + } + } + + > .widgetButton { + display: block; + position: fixed; + z-index: 1000; + bottom: 32px; + right: 32px; + width: 64px; + height: 64px; + border-radius: 100%; + box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12); + font-size: 22px; + background: var(--panel); + + &.navHidden { + display: none; + } + + @media (min-width: ($widgets-hide-threshold + 1px)) { + display: none; + } + } + + > .buttons { + position: fixed; + z-index: 1000; + bottom: 0; + padding: 0 32px 32px 32px; + display: flex; + width: 100%; + box-sizing: border-box; + background: linear-gradient(0deg, var(--bg), var(--X1)); + + @media (max-width: 500px) { + padding: 0 16px 16px 16px; + } + + &:not(.navHidden) { + 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); + background: var(--panel); + color: var(--fg); + + &:hover { + background: var(--X2); + } + + > i { + position: absolute; + top: 0; + left: 0; + color: var(--indicator); + font-size: 16px; + animation: blink 1s infinite; + } + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } + + > * { + font-size: 22px; + } + + &:disabled { + cursor: default; + + > * { + opacity: 0.5; + } + } + } + } + + > .tray-back { + z-index: 1001; + } + + > .tray { + position: fixed; + top: 0; + right: 0; + z-index: 1001; + // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + height: calc(var(--vh, 1vh) * 100); + padding: var(--margin); + box-sizing: border-box; + overflow: auto; + background: var(--bg); + } +} +</style> + +<style lang="scss"> +</style> diff --git a/src/client/ui/default.widgets.vue b/src/client/ui/default.widgets.vue new file mode 100644 index 0000000000..c41ba52a76 --- /dev/null +++ b/src/client/ui/default.widgets.vue @@ -0,0 +1,158 @@ +<template> +<div class="efzpzdvf"> + <template v-if="editMode"> + <MkButton primary @click="addWidget" class="add"><Fa :icon="faPlus"/></MkButton> + <XDraggable + :list="widgets" + handle=".handle" + animation="150" + class="sortable" + @sort="onWidgetSort" + > + <div v-for="widget in widgets" class="customize-container _panel" :key="widget.id"> + <header> + <span class="handle"><Fa :icon="faBars"/></span>{{ $t('_widgets.' + widget.name) }}<button class="remove _button" @click="removeWidget(widget)"><Fa :icon="faTimes"/></button> + </header> + <div @click="widgetFunc(widget.id)"> + <component class="_close_ _forceContainerFull_" :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :setting-callback="setting => settings[widget.id] = setting"/> + </div> + </div> + </XDraggable> + <button @click="editMode = false" class="_textButton" style="font-size: 0.9em;"><Fa :icon="faCheck"/> {{ $t('editWidgetsExit') }}</button> + </template> + <template v-else> + <component v-for="widget in widgets" class="_close_ _forceContainerFull_" :is="`mkw-${widget.name}`" :key="widget.id" :widget="widget"/> + <button @click="editMode = true" class="_textButton" style="font-size: 0.9em;"><Fa :icon="faPencilAlt"/> {{ $t('editWidgets') }}</button> + </template> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { v4 as uuid } from 'uuid'; +import { faPencilAlt, faPlus, faBars, faTimes, faCheck } from '@fortawesome/free-solid-svg-icons'; +import { widgets } from '@/widgets'; +import * as os from '@/os'; +import MkButton from '@/components/ui/button.vue'; + +export default defineComponent({ + components: { + MkButton, + XDraggable: defineAsyncComponent(() => import('vue-draggable-next').then(x => x.VueDraggableNext)), + }, + + emits: ['mounted'], + + data() { + return { + editMode: false, + settings: {}, + faPencilAlt, faPlus, faBars, faTimes, faCheck, + }; + }, + + computed: { + widgets(): any { + return this.$store.state.deviceUser.widgets; + }, + }, + + mounted() { + this.$emit('mounted', this.$el); + }, + + methods: { + widgetFunc(id) { + this.settings[id](); + }, + + onWidgetSort() { + // TODO: vuexを直接書き換えているのでなんとかする + this.saveHome(); + }, + + async addWidget() { + const { canceled, result: widget } = await os.dialog({ + type: null, + title: this.$t('chooseWidget'), + select: { + items: widgets.map(widget => ({ + value: widget, + text: this.$t('_widgets.' + widget), + })) + }, + showCancelButton: true + }); + if (canceled) return; + + this.$store.commit('deviceUser/addWidget', { + name: widget, + id: uuid(), + place: null, + data: {} + }); + }, + + removeWidget(widget) { + this.$store.commit('deviceUser/removeWidget', widget); + }, + + saveHome() { + this.$store.commit('deviceUser/setWidgets', this.widgets); + } + } +}); +</script> + +<style lang="scss" scoped> +.efzpzdvf { + position: sticky; + height: min-content; + min-height: 100vh; + padding: var(--margin) 0; + box-sizing: border-box; + + > * { + margin: var(--margin) 0; + width: 300px; + + &:first-child { + margin-top: 0; + } + } + + > .add { + margin: 0 auto; + } + + .customize-container { + margin: 8px 0; + + > header { + position: relative; + line-height: 32px; + + > .handle { + padding: 0 8px; + cursor: move; + } + + > .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/ui/visitor.vue b/src/client/ui/visitor.vue new file mode 100644 index 0000000000..fb21dc01d1 --- /dev/null +++ b/src/client/ui/visitor.vue @@ -0,0 +1,199 @@ +<template> +<div class="mk-app"> + <header> + <router-link class="link" to="/">{{ $t('home') }}</router-link> + <router-link class="link" to="/announcements">{{ $t('announcements') }}</router-link> + <router-link class="link" to="/channels">{{ $t('channel') }}</router-link> + <router-link class="link" to="/about">{{ $t('aboutX', { x: instanceName || host }) }}</router-link> + </header> + + <div class="banner" :style="{ backgroundImage: `url(${ $store.state.instance.meta.bannerUrl })` }"> + <h1>{{ instanceName || host }}</h1> + </div> + + <div class="contents" ref="contents" :class="{ wallpaper }"> + <header class="header" ref="header"> + <XHeader :info="pageInfo"/> + </header> + <main ref="main"> + <router-view v-slot="{ Component }"> + <transition :name="$store.state.device.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> + <component :is="Component" :ref="changePage"/> + </transition> + </router-view> + </main> + <div class="powered-by"> + <b><router-link to="/">{{ host }}</router-link></b> + <small>Powered by <a href="https://github.com/syuilo/misskey" target="_blank">Misskey</a></small> + </div> + </div> + + <StreamIndicator v-if="$store.getters.isSignedIn"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { } from '@fortawesome/free-solid-svg-icons'; +import { host, instanceName } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import XHeader from './_common_/header.vue'; + +const DESKTOP_THRESHOLD = 1100; + +export default defineComponent({ + components: { + XHeader + }, + + data() { + return { + host, + instanceName, + pageKey: 0, + pageInfo: null, + isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, + }; + }, + + computed: { + keymap(): any { + return { + 'd': () => { + if (this.$store.state.device.syncDeviceDarkMode) return; + this.$store.commit('device/set', { key: 'darkMode', value: !this.$store.state.device.darkMode }); + }, + 's': search, + 'h|/': this.help + }; + }, + }, + + watch: { + $route(to, from) { + this.pageKey++; + }, + }, + + created() { + document.documentElement.style.overflowY = 'scroll'; + }, + + mounted() { + if (!this.isDesktop) { + window.addEventListener('resize', () => { + if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true; + }, { passive: true }); + } + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page.INFO) { + this.pageInfo = page.INFO; + } + }, + + top() { + window.scroll({ top: 0, behavior: 'smooth' }); + }, + + help() { + this.$router.push('/docs/keyboard-shortcut'); + }, + + onTransition() { + if (window._scroll) window._scroll(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-app { + min-height: 100vh; + max-width: 1300px; + margin: 0 auto; + box-shadow: 1px 0 var(--divider), -1px 0 var(--divider); + + > header { + background: var(--panel); + padding: 0 16px; + text-align: center; + overflow: auto; + white-space: nowrap; + + > .link { + display: inline-block; + line-height: 60px; + padding: 0 0.7em; + + &.router-link-active { + box-shadow: 0 -2px 0 0 var(--accent) inset; + } + } + } + + > .banner { + position: relative; + width: 100%; + height: 200px; + background-size: cover; + background-position: center; + + &:after { + content: ""; + display: block; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(transparent, var(--bg)); + } + + > h1 { + margin: 0; + text-align: center; + color: #fff; + text-shadow: 0 0 8px #000; + line-height: 200px; + } + } + + > .contents { + > .header { + position: sticky; + top: 0; + left: 0; + z-index: 1000; + height: 60px; + width: 100%; + line-height: 60px; + text-align: center; + -webkit-backdrop-filter: blur(32px); + backdrop-filter: blur(32px); + background-color: var(--header); + border-bottom: 1px solid var(--divider); + } + + > .powered-by { + padding: 28px; + font-size: 14px; + text-align: center; + border-top: 1px solid var(--divider); + + > small { + display: block; + margin-top: 8px; + opacity: 0.5; + } + } + } +} +</style> + +<style lang="scss"> +</style> diff --git a/src/client/ui/zen.vue b/src/client/ui/zen.vue new file mode 100644 index 0000000000..66dfa72797 --- /dev/null +++ b/src/client/ui/zen.vue @@ -0,0 +1,154 @@ +<template> +<div class="mk-app" v-hotkey.global="keymap"> + <div class="contents"> + <header class="header"> + <XHeader :info="pageInfo"/> + </header> + <main ref="main"> + <div class="content"> + <router-view v-slot="{ Component }"> + <transition :name="$store.state.device.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> + <keep-alive :include="['timeline']"> + <component :is="Component" :ref="changePage"/> + </keep-alive> + </transition> + </router-view> + </div> + </main> + </div> + + <StreamIndicator/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { faLayerGroup, faBars, faHome, faCircle } from '@fortawesome/free-solid-svg-icons'; +import { faBell } from '@fortawesome/free-regular-svg-icons'; +import { host } from '@/config'; +import { search } from '@/scripts/search'; +import XHeader from './_common_/header.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XHeader, + }, + + data() { + return { + host: host, + pageKey: 0, + pageInfo: null, + connection: null, + faLayerGroup, faBars, faBell, faHome, faCircle, + }; + }, + + computed: { + keymap(): any { + return { + 'd': () => { + if (this.$store.state.device.syncDeviceDarkMode) return; + this.$store.commit('device/set', { key: 'darkMode', value: !this.$store.state.device.darkMode }); + }, + 'p': os.post, + 'n': os.post, + 's': search, + 'h|/': this.help + }; + }, + }, + + watch: { + $route(to, from) { + this.pageKey++; + }, + }, + + created() { + document.documentElement.style.overflowY = 'scroll'; + + this.connection = os.stream.useSharedConnection('main'); + this.connection.on('notification', this.onNotification); + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page.INFO) { + this.pageInfo = page.INFO; + } + }, + + top() { + window.scroll({ top: 0, behavior: 'smooth' }); + }, + + help() { + this.$router.push('/docs/keyboard-shortcut'); + }, + + onTransition() { + if (window._scroll) window._scroll(); + }, + + async onNotification(notification) { + if (this.$store.state.i.mutingNotificationTypes.includes(notification.type)) { + return; + } + if (document.visibilityState === 'visible') { + os.stream.send('readNotification', { + id: notification.id + }); + + os.popup(await import('@/components/toast.vue'), { + notification + }, {}, 'closed'); + } + + os.sound('notification'); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-app { + $header-height: 52px; + $ui-font-size: 1em; // TODO: どこかに集約したい + $widgets-hide-threshold: 1090px; + + // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + min-height: calc(var(--vh, 1vh) * 100); + box-sizing: border-box; + + > .contents { + padding-top: $header-height; + + > .header { + position: fixed; + z-index: 1000; + top: 0; + height: $header-height; + width: 100%; + line-height: $header-height; + text-align: center; + //background-color: var(--panel); + -webkit-backdrop-filter: blur(32px); + backdrop-filter: blur(32px); + background-color: var(--header); + border-bottom: solid 1px var(--divider); + } + + > main { + > .content { + > * { + // ほんとは単に calc(100vh - #{$header-height}) と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + min-height: calc((var(--vh, 1vh) * 100) - #{$header-height}); + } + } + } + } +} +</style> diff --git a/src/client/widgets/activity.calendar.vue b/src/client/widgets/activity.calendar.vue index 334c2ea56e..b833bd65ca 100644 --- a/src/client/widgets/activity.calendar.vue +++ b/src/client/widgets/activity.calendar.vue @@ -24,9 +24,10 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; +import * as os from '@/os'; -export default Vue.extend({ +export default defineComponent({ props: ['data'], created() { for (const d of this.data) { diff --git a/src/client/widgets/activity.chart.vue b/src/client/widgets/activity.chart.vue index 2b70493552..9702d66663 100644 --- a/src/client/widgets/activity.chart.vue +++ b/src/client/widgets/activity.chart.vue @@ -25,7 +25,8 @@ </template> <script lang="ts"> -import Vue from 'vue'; +import { defineComponent } from 'vue'; +import * as os from '@/os'; function dragListen(fn) { window.addEventListener('mousemove', fn); @@ -39,7 +40,7 @@ function dragClear(fn) { window.removeEventListener('mouseup', dragClear); } -export default Vue.extend({ +export default defineComponent({ props: ['data'], data() { return { diff --git a/src/client/widgets/activity.vue b/src/client/widgets/activity.vue index 58b1631367..8db13723ec 100644 --- a/src/client/widgets/activity.vue +++ b/src/client/widgets/activity.vue @@ -1,26 +1,28 @@ <template> -<mk-container :show-header="props.showHeader" :naked="props.transparent"> - <template #header><fa :icon="faChartBar"/>{{ $t('_widgets.activity') }}</template> - <template #func><button @click="toggleView()" class="_button"><fa :icon="faSort"/></button></template> +<MkContainer :show-header="props.showHeader" :naked="props.transparent"> + <template #header><Fa :icon="faChartBar"/>{{ $t('_widgets.activity') }}</template> + <template #func><button @click="toggleView()" class="_button"><Fa :icon="faSort"/></button></template> <div> - <mk-loading v-if="fetching"/> + <MkLoading v-if="fetching"/> <template v-else> - <x-calendar v-show="props.view === 0" :data="[].concat(activity)"/> - <x-chart v-show="props.view === 1" :data="[].concat(activity)"/> + <XCalendar v-show="props.view === 0" :data="[].concat(activity)"/> + <XChart v-show="props.view === 1" :data="[].concat(activity)"/> </template> </div> -</mk-container> +</MkContainer> </template> <script lang="ts"> +import { defineComponent } from 'vue'; import { faChartBar, faSort } from '@fortawesome/free-solid-svg-icons'; -import MkContainer from '../components/ui/container.vue'; +import MkContainer from '@/components/ui/container.vue'; import define from './define'; import XCalendar from './activity.calendar.vue'; import XChart from './activity.chart.vue'; +import * as os from '@/os'; -export default define({ +const widget = define({ name: 'activity', props: () => ({ showHeader: { @@ -37,7 +39,10 @@ export default define({ hidden: true, }, }) -}).extend({ +}); + +export default defineComponent({ + extends: widget, components: { MkContainer, XCalendar, @@ -51,7 +56,7 @@ export default define({ }; }, mounted() { - this.$root.api('charts/user/notes', { + os.api('charts/user/notes', { userId: this.$store.state.i.id, span: 'day', limit: 7 * 21 diff --git a/src/client/widgets/calendar.vue b/src/client/widgets/calendar.vue index 8ef74ff744..d464f27ec4 100644 --- a/src/client/widgets/calendar.vue +++ b/src/client/widgets/calendar.vue @@ -1,6 +1,6 @@ <template> <div class="mkw-calendar" :class="{ _panel: !props.transparent }"> - <div class="calendar" :data-is-holiday="isHoliday"> + <div class="calendar" :class="{ isHoliday }"> <p class="month-and-year"> <span class="year">{{ $t('yearX', { year }) }}</span> <span class="month">{{ $t('monthX', { month }) }}</span> @@ -32,9 +32,11 @@ </template> <script lang="ts"> +import { defineComponent } from 'vue'; import define from './define'; +import * as os from '@/os'; -export default define({ +const widget = define({ name: 'calendar', props: () => ({ transparent: { @@ -42,7 +44,10 @@ export default define({ default: false, }, }) -}).extend({ +}); + +export default defineComponent({ + extends: widget, data() { return { now: new Date(), @@ -61,7 +66,7 @@ export default define({ this.tick(); this.clock = setInterval(this.tick, 1000); }, - beforeDestroy() { + beforeUnmount() { clearInterval(this.clock); }, methods: { @@ -116,7 +121,7 @@ export default define({ width: 60%; text-align: center; - &[data-is-holiday] { + &.isHoliday { > .day { color: #ef95a0; } diff --git a/src/client/widgets/clock.vue b/src/client/widgets/clock.vue index 6388324125..2aad24baec 100644 --- a/src/client/widgets/clock.vue +++ b/src/client/widgets/clock.vue @@ -1,17 +1,19 @@ <template> -<mk-container :naked="props.transparent" :show-header="false"> +<MkContainer :naked="props.transparent" :show-header="false"> <div class="vubelbmv"> - <mk-analog-clock class="clock"/> + <MkAnalogClock class="clock"/> </div> -</mk-container> +</MkContainer> </template> <script lang="ts"> +import { defineComponent } from 'vue'; import define from './define'; -import MkContainer from '../components/ui/container.vue'; -import MkAnalogClock from '../components/analog-clock.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkAnalogClock from '@/components/analog-clock.vue'; +import * as os from '@/os'; -export default define({ +const widget = define({ name: 'clock', props: () => ({ transparent: { @@ -19,7 +21,10 @@ export default define({ default: false, }, }) -}).extend({ +}); + +export default defineComponent({ + extends: widget, components: { MkContainer, MkAnalogClock diff --git a/src/client/widgets/define.ts b/src/client/widgets/define.ts index 50c9b10e81..c199d38e72 100644 --- a/src/client/widgets/define.ts +++ b/src/client/widgets/define.ts @@ -1,30 +1,32 @@ -import Vue from 'vue'; -import { Form } from '../scripts/form'; +import { defineComponent } from 'vue'; +import { Form } from '@/scripts/form'; +import * as os from '@/os'; export default function <T extends Form>(data: { name: string; props?: () => T; }) { - return Vue.extend({ + return defineComponent({ props: { widget: { type: Object, required: false }, - isCustomizeMode: { - type: Boolean, - default: false + settingCallback: { + required: false } }, + data() { + return { + props: this.widget ? JSON.parse(JSON.stringify(this.widget.data)) : {} + }; + }, + computed: { id(): string { return this.widget ? this.widget.id : null; }, - - props(): Record<string, any> { - return this.widget ? this.widget.data : {}; - } }, created() { @@ -32,7 +34,9 @@ export default function <T extends Form>(data: { this.$watch('props', () => { this.mergeProps(); - }); + }, { deep: true }); + + if (this.settingCallback) this.settingCallback(this.setting); }, methods: { @@ -41,7 +45,7 @@ export default function <T extends Form>(data: { const defaultProps = data.props(); for (const prop of Object.keys(defaultProps)) { if (this.props.hasOwnProperty(prop)) continue; - Vue.set(this.props, prop, defaultProps[prop].default); + this.props[prop] = defaultProps[prop].default; } } }, @@ -51,11 +55,11 @@ export default function <T extends Form>(data: { for (const item of Object.keys(form)) { form[item].default = this.props[item]; } - const { canceled, result } = await this.$root.form(data.name, form); + const { canceled, result } = await os.form(data.name, form); if (canceled) return; for (const key of Object.keys(result)) { - Vue.set(this.props, key, result[key]); + this.props[key] = result[key]; } this.save(); @@ -63,7 +67,10 @@ export default function <T extends Form>(data: { save() { if (this.widget) { - this.$store.commit('deviceUser/updateWidget', this.widget); + this.$store.commit('deviceUser/updateWidget', { + ...this.widget, + data: this.props + }); } } } diff --git a/src/client/widgets/digital-clock.vue b/src/client/widgets/digital-clock.vue index 0e68fe0ff4..702f335c7f 100644 --- a/src/client/widgets/digital-clock.vue +++ b/src/client/widgets/digital-clock.vue @@ -13,9 +13,11 @@ </template> <script lang="ts"> +import { defineComponent } from 'vue'; import define from './define'; +import * as os from '@/os'; -export default define({ +const widget = define({ name: 'digitalClock', props: () => ({ transparent: { @@ -32,7 +34,10 @@ export default define({ default: true, }, }) -}).extend({ +}); + +export default defineComponent({ + extends: widget, data() { return { clock: null, @@ -45,12 +50,12 @@ export default define({ }, created() { this.tick(); - this.$watch('props.showMs', () => { + this.$watch(() => this.props.showMs, () => { if (this.clock) clearInterval(this.clock); this.clock = setInterval(this.tick, this.props.showMs ? 10 : 1000); }, { immediate: true }); }, - beforeDestroy() { + beforeUnmount() { clearInterval(this.clock); }, methods: { diff --git a/src/client/widgets/federation.vue b/src/client/widgets/federation.vue index 7c3fc62381..5cfa87e58f 100644 --- a/src/client/widgets/federation.vue +++ b/src/client/widgets/federation.vue @@ -1,9 +1,9 @@ <template> -<mk-container :show-header="props.showHeader" :body-togglable="bodyTogglable" :scrollable="scrollable"> - <template #header><fa :icon="faGlobe"/>{{ $t('_widgets.federation') }}</template> +<MkContainer :show-header="props.showHeader" :body-togglable="bodyTogglable" :scrollable="scrollable"> + <template #header><Fa :icon="faGlobe"/>{{ $t('_widgets.federation') }}</template> <div class="wbrkwalb"> - <mk-loading v-if="fetching"/> + <MkLoading v-if="fetching"/> <transition-group tag="div" name="chart" class="instances" v-else> <div v-for="(instance, i) in instances" :key="instance.id" class="instance"> <img v-if="instance.iconUrl" :src="instance.iconUrl" alt=""/> @@ -11,20 +11,22 @@ <a class="a" :href="'https://' + instance.host" target="_blank" :title="instance.host">{{ instance.host }}</a> <p>{{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</p> </div> - <mk-mini-chart class="chart" :src="charts[i].requests.received"/> + <MkMiniChart class="chart" :src="charts[i].requests.received"/> </div> </transition-group> </div> -</mk-container> +</MkContainer> </template> <script lang="ts"> +import { defineComponent } from 'vue'; import { faGlobe } from '@fortawesome/free-solid-svg-icons'; -import MkContainer from '../components/ui/container.vue'; +import MkContainer from '@/components/ui/container.vue'; import define from './define'; -import MkMiniChart from '../components/mini-chart.vue'; +import MkMiniChart from '@/components/mini-chart.vue'; +import * as os from '@/os'; -export default define({ +const widget = define({ name: 'federation', props: () => ({ showHeader: { @@ -32,7 +34,10 @@ export default define({ default: true, }, }) -}).extend({ +}); + +export default defineComponent({ + extends: widget, components: { MkContainer, MkMiniChart }, @@ -60,16 +65,16 @@ export default define({ this.fetch(); this.clock = setInterval(this.fetch, 1000 * 60); }, - beforeDestroy() { + beforeUnmount() { clearInterval(this.clock); }, methods: { async fetch() { - const instances = await this.$root.api('federation/instances', { + const instances = await os.api('federation/instances', { sort: '+lastCommunicatedAt', limit: 5 }); - const charts = await Promise.all(instances.map(i => this.$root.api('charts/instance', { host: i.host, limit: 16, span: 'hour' }))); + const charts = await Promise.all(instances.map(i => os.api('charts/instance', { host: i.host, limit: 16, span: 'hour' }))); this.instances = instances; this.charts = charts; this.fetching = false; diff --git a/src/client/widgets/index.ts b/src/client/widgets/index.ts index 743146193c..ee76bd97d2 100644 --- a/src/client/widgets/index.ts +++ b/src/client/widgets/index.ts @@ -1,17 +1,19 @@ -import Vue from 'vue'; +import { App, defineAsyncComponent } from 'vue'; -Vue.component('mkw-welcome', () => import('./welcome.vue').then(m => m.default)); -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)); -Vue.component('mkw-clock', () => import('./clock.vue').then(m => m.default)); -Vue.component('mkw-activity', () => import('./activity.vue').then(m => m.default)); -Vue.component('mkw-photos', () => import('./photos.vue').then(m => m.default)); -Vue.component('mkw-digitalClock', () => import('./digital-clock.vue').then(m => m.default)); -Vue.component('mkw-federation', () => import('./federation.vue').then(m => m.default)); +export default function(app: App) { + app.component('MkwMemo', defineAsyncComponent(() => import('./memo.vue'))); + app.component('MkwNotifications', defineAsyncComponent(() => import('./notifications.vue'))); + app.component('MkwTimeline', defineAsyncComponent(() => import('./timeline.vue'))); + app.component('MkwCalendar', defineAsyncComponent(() => import('./calendar.vue'))); + app.component('MkwRss', defineAsyncComponent(() => import('./rss.vue'))); + app.component('MkwTrends', defineAsyncComponent(() => import('./trends.vue'))); + app.component('MkwClock', defineAsyncComponent(() => import('./clock.vue'))); + app.component('MkwActivity', defineAsyncComponent(() => import('./activity.vue'))); + app.component('MkwPhotos', defineAsyncComponent(() => import('./photos.vue'))); + app.component('MkwDigitalClock', defineAsyncComponent(() => import('./digital-clock.vue'))); + app.component('MkwFederation', defineAsyncComponent(() => import('./federation.vue'))); + app.component('MkwPostForm', defineAsyncComponent(() => import('./post-form.vue'))); +} export const widgets = [ 'memo', @@ -25,4 +27,5 @@ export const widgets = [ 'photos', 'digitalClock', 'federation', + 'postForm', ]; diff --git a/src/client/widgets/memo.vue b/src/client/widgets/memo.vue index 0d319b225e..8b14d61c73 100644 --- a/src/client/widgets/memo.vue +++ b/src/client/widgets/memo.vue @@ -1,20 +1,22 @@ <template> -<mk-container :show-header="props.showHeader"> - <template #header><fa :icon="faStickyNote"/>{{ $t('_widgets.memo') }}</template> +<MkContainer :show-header="props.showHeader"> + <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" class="_buttonPrimary">{{ $t('save') }}</button> </div> -</mk-container> +</MkContainer> </template> <script lang="ts"> +import { defineComponent } from 'vue'; import { faStickyNote } from '@fortawesome/free-solid-svg-icons'; -import MkContainer from '../components/ui/container.vue'; +import MkContainer from '@/components/ui/container.vue'; import define from './define'; +import * as os from '@/os'; -export default define({ +const widget = define({ name: 'memo', props: () => ({ showHeader: { @@ -22,7 +24,10 @@ export default define({ default: true, }, }) -}).extend({ +}); + +export default defineComponent({ + extends: widget, components: { MkContainer }, @@ -39,7 +44,7 @@ export default define({ created() { this.text = this.$store.state.settings.memo; - this.$watch('$store.state.settings.memo', text => { + this.$watch(() => this.$store.state.settings.memo, text => { this.text = text; }); }, diff --git a/src/client/widgets/notifications.vue b/src/client/widgets/notifications.vue index 9d6282735b..490c4d758f 100644 --- a/src/client/widgets/notifications.vue +++ b/src/client/widgets/notifications.vue @@ -1,21 +1,23 @@ <template> -<mk-container :style="`height: ${props.height}px;`" :show-header="props.showHeader" :scrollable="true"> - <template #header><fa :icon="faBell"/>{{ $t('notifications') }}</template> - <template #func><button @click="configure()" class="_button"><fa :icon="faCog"/></button></template> +<MkContainer :style="`height: ${props.height}px;`" :show-header="props.showHeader" :scrollable="true"> + <template #header><Fa :icon="faBell"/>{{ $t('notifications') }}</template> + <template #func><button @click="configure()" class="_button"><Fa :icon="faCog"/></button></template> <div> - <x-notifications :include-types="props.includingTypes"/> + <XNotifications :include-types="props.includingTypes"/> </div> -</mk-container> +</MkContainer> </template> <script lang="ts"> +import { defineComponent } from 'vue'; import { faBell, faCog } from '@fortawesome/free-solid-svg-icons'; -import MkContainer from '../components/ui/container.vue'; -import XNotifications from '../components/notifications.vue'; +import MkContainer from '@/components/ui/container.vue'; +import XNotifications from '@/components/notifications.vue'; import define from './define'; +import * as os from '@/os'; -export default define({ +const widget = define({ name: 'notifications', props: () => ({ showHeader: { @@ -32,7 +34,11 @@ export default define({ default: null, }, }) -}).extend({ +}); + +export default defineComponent({ + extends: widget, + components: { MkContainer, XNotifications, @@ -46,12 +52,15 @@ export default define({ methods: { async configure() { - this.$root.new(await import('../components/notification-setting-window.vue').then(m => m.default), { + os.popup(await import('@/components/notification-setting-window.vue'), { includingTypes: this.props.includingTypes, - }).$on('ok', async ({ includingTypes }) => { - this.props.includingTypes = includingTypes; - this.save(); - }); + }, { + done: async (res) => { + const { includingTypes } = res; + this.props.includingTypes = includingTypes; + this.save(); + } + }, 'closed'); } } }); diff --git a/src/client/widgets/photos.vue b/src/client/widgets/photos.vue index 2b8399df9b..2fa0a48574 100644 --- a/src/client/widgets/photos.vue +++ b/src/client/widgets/photos.vue @@ -1,9 +1,9 @@ <template> -<mk-container :show-header="props.showHeader" :naked="props.transparent" :class="$style.root" :data-transparent="props.transparent"> - <template #header><fa :icon="faCamera"/>{{ $t('_widgets.photos') }}</template> +<MkContainer :show-header="props.showHeader" :naked="props.transparent" :class="$style.root" :data-transparent="props.transparent ? true : null"> + <template #header><Fa :icon="faCamera"/>{{ $t('_widgets.photos') }}</template> <div class=""> - <mk-loading v-if="fetching"/> + <MkLoading v-if="fetching"/> <div v-else :class="$style.stream"> <div v-for="(image, i) in images" :key="i" :class="$style.img" @@ -11,16 +11,18 @@ ></div> </div> </div> -</mk-container> +</MkContainer> </template> <script lang="ts"> +import { defineComponent } from 'vue'; import { faCamera } from '@fortawesome/free-solid-svg-icons'; -import MkContainer from '../components/ui/container.vue'; +import MkContainer from '@/components/ui/container.vue'; import define from './define'; -import { getStaticImageUrl } from '../scripts/get-static-image-url'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import * as os from '@/os'; -export default define({ +const widget = define({ name: 'photos', props: () => ({ showHeader: { @@ -32,7 +34,10 @@ export default define({ default: false, }, }) -}).extend({ +}); + +export default defineComponent({ + extends: widget, components: { MkContainer, }, @@ -45,11 +50,11 @@ export default define({ }; }, mounted() { - this.connection = this.$root.stream.useSharedConnection('main'); + this.connection = os.stream.useSharedConnection('main'); this.connection.on('driveFileCreated', this.onDriveFileCreated); - this.$root.api('drive/stream', { + os.api('drive/stream', { type: 'image/*', limit: 9 }).then(images => { @@ -57,7 +62,7 @@ export default define({ this.fetching = false; }); }, - beforeDestroy() { + beforeUnmount() { this.connection.dispose(); }, methods: { diff --git a/src/client/widgets/post-form.vue b/src/client/widgets/post-form.vue new file mode 100644 index 0000000000..5ecaa67b5a --- /dev/null +++ b/src/client/widgets/post-form.vue @@ -0,0 +1,23 @@ +<template> +<XPostForm class="_panel" :fixed="true" :autofocus="false"/> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XPostForm from '@/components/post-form.vue'; +import define from './define'; + +const widget = define({ + name: 'postForm', + props: () => ({ + }) +}); + +export default defineComponent({ + extends: widget, + + components: { + XPostForm, + }, +}); +</script> diff --git a/src/client/widgets/rss.vue b/src/client/widgets/rss.vue index 3a76c8fb4f..ba84ceefa3 100644 --- a/src/client/widgets/rss.vue +++ b/src/client/widgets/rss.vue @@ -1,23 +1,25 @@ <template> -<mk-container :show-header="props.showHeader"> - <template #header><fa :icon="faRssSquare"/>RSS</template> - <template #func><button class="_button" @click="setting"><fa :icon="faCog"/></button></template> +<MkContainer :show-header="props.showHeader"> + <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"/> + <MkLoading v-if="fetching"/> <div class="feed" v-else> <a v-for="item in items" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a> </div> </div> -</mk-container> +</MkContainer> </template> <script lang="ts"> +import { defineComponent } from 'vue'; import { faRssSquare, faCog } from '@fortawesome/free-solid-svg-icons'; -import MkContainer from '../components/ui/container.vue'; +import MkContainer from '@/components/ui/container.vue'; import define from './define'; +import * as os from '@/os'; -export default define({ +const widget = define({ name: 'rss', props: () => ({ showHeader: { @@ -29,7 +31,10 @@ export default define({ default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews', }, }) -}).extend({ +}); + +export default defineComponent({ + extends: widget, components: { MkContainer }, @@ -44,9 +49,9 @@ export default define({ mounted() { this.fetch(); this.clock = setInterval(this.fetch, 60000); - this.$watch('props.url', this.fetch); + this.$watch(() => this.props.url, this.fetch); }, - beforeDestroy() { + beforeUnmount() { clearInterval(this.clock); }, methods: { diff --git a/src/client/widgets/timeline.vue b/src/client/widgets/timeline.vue index ca6af76126..2c98b013d3 100644 --- a/src/client/widgets/timeline.vue +++ b/src/client/widgets/timeline.vue @@ -1,32 +1,34 @@ <template> -<mk-container :show-header="props.showHeader" :style="`height: ${props.height}px;`" :scrollable="true"> +<MkContainer :show-header="props.showHeader" :style="`height: ${props.height}px;`" :scrollable="true"> <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"/> + <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;"/> + <Fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/> </button> </template> <div> - <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 ? props.list.id : null" :antenna="props.antenna ? props.antenna.id : null"/> + <XTimeline :key="props.src === 'list' ? `list:${props.list.id}` : props.src === 'antenna' ? `antenna:${props.antenna.id}` : props.src" :src="props.src" :list="props.list ? props.list.id : null" :antenna="props.antenna ? props.antenna.id : null"/> </div> -</mk-container> +</MkContainer> </template> <script lang="ts"> +import { defineComponent } from 'vue'; 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 MkContainer from '@/components/ui/container.vue'; +import XTimeline from '@/components/timeline.vue'; import define from './define'; +import * as os from '@/os'; -export default define({ +const widget = define({ name: 'timeline', props: () => ({ showHeader: { @@ -48,7 +50,10 @@ export default define({ hidden: true, }, }) -}).extend({ +}); + +export default defineComponent({ + extends: widget, components: { MkContainer, XTimeline, @@ -65,8 +70,8 @@ export default define({ async choose(ev) { this.menuOpened = true; const [antennas, lists] = await Promise.all([ - this.$root.api('antennas/list'), - this.$root.api('users/lists/list') + os.api('antennas/list'), + os.api('users/lists/list') ]); const antennaItems = antennas.map(antenna => ({ text: antenna.name, @@ -84,27 +89,23 @@ export default define({ 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(() => { + os.modalMenu([{ + 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], ev.currentTarget || ev.target).then(() => { this.menuOpened = false; }); }, diff --git a/src/client/widgets/trends.vue b/src/client/widgets/trends.vue index b439f91d54..17262445ef 100644 --- a/src/client/widgets/trends.vue +++ b/src/client/widgets/trends.vue @@ -1,29 +1,31 @@ <template> -<mk-container :show-header="props.showHeader"> - <template #header><fa :icon="faHashtag"/>{{ $t('_widgets.trends') }}</template> +<MkContainer :show-header="props.showHeader"> + <template #header><Fa :icon="faHashtag"/>{{ $t('_widgets.trends') }}</template> <div class="wbrkwala"> - <mk-loading v-if="fetching"/> + <MkLoading v-if="fetching"/> <transition-group tag="div" name="chart" class="tags" v-else> <div v-for="stat in stats" :key="stat.tag"> <div class="tag"> <router-link class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</router-link> <p>{{ $t('nUsersMentioned', { n: stat.usersCount }) }}</p> </div> - <mk-mini-chart class="chart" :src="stat.chart"/> + <MkMiniChart class="chart" :src="stat.chart"/> </div> </transition-group> </div> -</mk-container> +</MkContainer> </template> <script lang="ts"> +import { defineComponent } from 'vue'; import { faHashtag } from '@fortawesome/free-solid-svg-icons'; -import MkContainer from '../components/ui/container.vue'; +import MkContainer from '@/components/ui/container.vue'; import define from './define'; -import MkMiniChart from '../components/mini-chart.vue'; +import MkMiniChart from '@/components/mini-chart.vue'; +import * as os from '@/os'; -export default define({ +const widget = define({ name: 'hashtags', props: () => ({ showHeader: { @@ -31,7 +33,10 @@ export default define({ default: true, }, }) -}).extend({ +}); + +export default defineComponent({ + extends: widget, components: { MkContainer, MkMiniChart }, @@ -46,12 +51,12 @@ export default define({ this.fetch(); this.clock = setInterval(this.fetch, 1000 * 60); }, - beforeDestroy() { + beforeUnmount() { clearInterval(this.clock); }, methods: { fetch() { - this.$root.api('hashtags/trend').then(stats => { + os.api('hashtags/trend').then(stats => { this.stats = stats; this.fetching = false; }); diff --git a/src/client/widgets/welcome.vue b/src/client/widgets/welcome.vue deleted file mode 100644 index f6169935f0..0000000000 --- a/src/client/widgets/welcome.vue +++ /dev/null @@ -1,87 +0,0 @@ -<template> -<div class="mkw-welcome _panel" v-if="meta"> - <div class="banner" :style="{ backgroundImage: `url(${ meta.bannerUrl })` }"></div> - <div class="body"> - <h1 class="name" v-html="meta.name || host"></h1> - <div class="desc" v-html="meta.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> -</template> - -<script lang="ts"> -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 { host } from '../config'; -import define from './define'; - -export default define({ - name: 'welcome', - props: () => ({ - }) -}).extend({ - components: { - MkButton - }, - - data() { - return { - host: toUnicode(host), - }; - }, - - computed: { - meta() { - return this.$store.state.instance.meta; - }, - }, - - created() { - this.$root.api('stats').then(stats => { - this.stats = stats; - }); - }, - - methods: { - signin() { - this.$root.new(XSigninDialog, { - autoSet: true - }); - }, - - signup() { - this.$root.new(XSignupDialog, { - autoSet: true - }); - } - } -}); -</script> - -<style lang="scss" scoped> -.mkw-welcome { - overflow: hidden; - - > .banner { - height: 90px; - background-size: cover; - background-position: center center; - } - - > .body { - padding: 16px; - - > .name { - font-size: 1.2em; - margin: 0 0 0.5em 0; - } - - > .desc { - font-size: 0.9em; - } - } -} -</style> diff --git a/src/mfm/to-html.ts b/src/mfm/to-html.ts index 9376889829..5b21384608 100644 --- a/src/mfm/to-html.ts +++ b/src/mfm/to-html.ts @@ -171,7 +171,7 @@ export function toHtml(tokens: MfmForest | null, mentionedRemoteUsers: IMentione search(token) { const a = doc.createElement('a'); - a.href = `https://www.google.com/?#q=${token.node.props.query}`; + a.href = `https://www.google.com/search?q=${token.node.props.query}`; a.textContent = token.node.props.content; return a; } diff --git a/src/misc/get-file-info.ts b/src/misc/get-file-info.ts index ce177cc53d..39ba541395 100644 --- a/src/misc/get-file-info.ts +++ b/src/misc/get-file-info.ts @@ -181,7 +181,16 @@ function getBlurhash(path: string): Promise<string> { .resize(64, 64, { fit: 'inside' }) .toBuffer((err, buffer, { width, height }) => { if (err) return reject(err); - resolve(encode(new Uint8ClampedArray(buffer), width, height, 7, 7)); + + let hash; + + try { + hash = encode(new Uint8ClampedArray(buffer), width, height, 7, 7); + } catch (e) { + return reject(e); + } + + resolve(hash); }); }); } diff --git a/src/models/repositories/drive-file.ts b/src/models/repositories/drive-file.ts index 6bdf62be8b..e5739408db 100644 --- a/src/models/repositories/drive-file.ts +++ b/src/models/repositories/drive-file.ts @@ -119,10 +119,12 @@ export class DriveFileRepository extends Repository<DriveFile> { properties: file.properties, url: opts.self ? file.url : this.getPublicUrl(file, false, meta), thumbnailUrl: this.getPublicUrl(file, true, meta), + comment: file.comment, folderId: file.folderId, folder: opts.detail && file.folderId ? DriveFolders.pack(file.folderId, { detail: true }) : null, + userId: opts.withUser ? file.userId! : null, user: opts.withUser ? Users.pack(file.userId!) : null }); } diff --git a/src/server/api/endpoints/admin/drive/files.ts b/src/server/api/endpoints/admin/drive/files.ts index c5a91db854..f6296b8947 100644 --- a/src/server/api/endpoints/admin/drive/files.ts +++ b/src/server/api/endpoints/admin/drive/files.ts @@ -1,7 +1,8 @@ import $ from 'cafy'; import define from '../../../define'; -import { fallback } from '../../../../../prelude/symbol'; import { DriveFiles } from '../../../../../models'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; +import { ID } from '../../../../../misc/cafy-id'; export const meta = { tags: ['admin'], @@ -15,18 +16,16 @@ export const meta = { default: 10 }, - offset: { - validator: $.optional.num.min(0), - default: 0 + sinceId: { + validator: $.optional.type(ID), }, - sort: { - validator: $.optional.str.or([ - '+createdAt', - '-createdAt', - '+size', - '-size', - ]), + untilId: { + validator: $.optional.type(ID), + }, + + type: { + validator: $.optional.nullable.str.match(/^[a-zA-Z\/\-*]+$/) }, origin: { @@ -36,30 +35,37 @@ export const meta = { 'remote', ]), default: 'local' - } - } -}; + }, -const sort: any = { // < https://github.com/Microsoft/TypeScript/issues/1863 - '+createdAt': { createdAt: -1 }, - '-createdAt': { createdAt: 1 }, - '+size': { size: -1 }, - '-size': { size: 1 }, - [fallback]: { id: -1 } + hostname: { + validator: $.optional.nullable.str, + default: null + }, + } }; export default define(meta, async (ps, me) => { - const q = {} as any; + const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId); + + if (ps.origin === 'local') { + query.andWhere('file.userHost IS NULL'); + } else if (ps.origin === 'remote') { + query.andWhere('file.userHost IS NOT NULL'); + } - if (ps.origin === 'local') q['userHost'] = null; - if (ps.origin === 'remote') q['userHost'] = { $ne: null }; + if (ps.hostname) { + query.andWhere('file.userHost = :hostname', { hostname: ps.hostname }); + } + + if (ps.type) { + if (ps.type.endsWith('/*')) { + query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); + } else { + query.andWhere('file.type = :type', { type: ps.type }); + } + } - const files = await DriveFiles.find({ - where: q, - take: ps.limit!, - order: sort[ps.sort!] || sort[fallback], - skip: ps.offset - }); + const files = await query.take(ps.limit!).getMany(); return await DriveFiles.packMany(files, { detail: true, withUser: true, self: true }); }); diff --git a/src/server/api/endpoints/admin/drive/show-file.ts b/src/server/api/endpoints/admin/drive/show-file.ts index 415bfc28b3..36403bb1c3 100644 --- a/src/server/api/endpoints/admin/drive/show-file.ts +++ b/src/server/api/endpoints/admin/drive/show-file.ts @@ -12,7 +12,11 @@ export const meta = { params: { fileId: { - validator: $.type(ID), + validator: $.optional.type(ID), + }, + + url: { + validator: $.optional.str, }, }, @@ -26,7 +30,15 @@ export const meta = { }; export default define(meta, async (ps, me) => { - const file = await DriveFiles.findOne(ps.fileId); + const file = ps.fileId ? await DriveFiles.findOne(ps.fileId) : await DriveFiles.findOne({ + where: [{ + url: ps.url + }, { + thumbnailUrl: ps.url + }, { + webpublicUrl: ps.url + }] + }); if (file == null) { throw new ApiError(meta.errors.noSuchFile); diff --git a/src/server/api/endpoints/admin/emoji/list-remote.ts b/src/server/api/endpoints/admin/emoji/list-remote.ts index 7ced4623bb..cbdcaa681c 100644 --- a/src/server/api/endpoints/admin/emoji/list-remote.ts +++ b/src/server/api/endpoints/admin/emoji/list-remote.ts @@ -16,6 +16,11 @@ export const meta = { requireModerator: true, params: { + query: { + validator: $.optional.nullable.str, + default: null as any + }, + host: { validator: $.optional.nullable.str, default: null as any @@ -45,9 +50,12 @@ export default define(meta, async (ps) => { q.andWhere(`emoji.host = :host`, { host: toPuny(ps.host) }); } + if (ps.query) { + q.andWhere('emoji.name like :query', { query: '%' + ps.query + '%' }); + } + const emojis = await q - .orderBy('emoji.category', 'ASC') - .orderBy('emoji.name', 'ASC') + .orderBy('emoji.id', 'DESC') .take(ps.limit!) .getMany(); diff --git a/src/server/api/endpoints/admin/emoji/list.ts b/src/server/api/endpoints/admin/emoji/list.ts index e3aab4cf7c..bd3e294851 100644 --- a/src/server/api/endpoints/admin/emoji/list.ts +++ b/src/server/api/endpoints/admin/emoji/list.ts @@ -3,6 +3,7 @@ import define from '../../../define'; import { Emojis } from '../../../../../models'; import { makePaginationQuery } from '../../../common/make-pagination-query'; import { ID } from '../../../../../misc/cafy-id'; +import { Emoji } from '../../../../../models/entities/emoji'; export const meta = { desc: { @@ -15,6 +16,11 @@ export const meta = { requireModerator: true, params: { + query: { + validator: $.optional.nullable.str, + default: null as any + }, + limit: { validator: $.optional.num.range(1, 100), default: 10 @@ -31,10 +37,26 @@ export const meta = { }; export default define(meta, async (ps) => { - const emojis = await makePaginationQuery(Emojis.createQueryBuilder('emoji'), ps.sinceId, ps.untilId) - .andWhere(`emoji.host IS NULL`) - .take(ps.limit!) - .getMany(); + const q = makePaginationQuery(Emojis.createQueryBuilder('emoji'), ps.sinceId, ps.untilId) + .andWhere(`emoji.host IS NULL`); + + let emojis: Emoji[]; + + if (ps.query) { + //q.andWhere('emoji.name ILIKE :q', { q: `%${ps.query}%` }); + //const emojis = await q.take(ps.limit!).getMany(); + + emojis = await q.getMany(); + + emojis = emojis.filter(emoji => + emoji.name.includes(ps.query) || + emoji.aliases.some(a => a.includes(ps.query)) || + emoji.category?.includes(ps.query)); + + emojis.splice(ps.limit! + 1); + } else { + emojis = await q.take(ps.limit!).getMany(); + } return Emojis.packMany(emojis); }); diff --git a/src/server/api/endpoints/admin/get-table-stats.ts b/src/server/api/endpoints/admin/get-table-stats.ts index f850d18380..c23f269437 100644 --- a/src/server/api/endpoints/admin/get-table-stats.ts +++ b/src/server/api/endpoints/admin/get-table-stats.ts @@ -4,6 +4,7 @@ import { getConnection } from 'typeorm'; export const meta = { requireCredential: true as const, requireAdmin: true, + requireModerator: true, desc: { 'en-US': 'Get table stats' diff --git a/src/server/api/endpoints/admin/server-info.ts b/src/server/api/endpoints/admin/server-info.ts index abed71cc14..f697f02e91 100644 --- a/src/server/api/endpoints/admin/server-info.ts +++ b/src/server/api/endpoints/admin/server-info.ts @@ -7,6 +7,7 @@ import redis from '../../../../db/redis'; export const meta = { requireCredential: true as const, requireAdmin: true, + requireModerator: true, desc: { }, diff --git a/src/server/api/endpoints/drive/files.ts b/src/server/api/endpoints/drive/files.ts index 1a8a21d630..00705fb9b7 100644 --- a/src/server/api/endpoints/drive/files.ts +++ b/src/server/api/endpoints/drive/files.ts @@ -36,7 +36,7 @@ export const meta = { }, type: { - validator: $.optional.str.match(/^[a-zA-Z\/\-*]+$/) + validator: $.optional.nullable.str.match(/^[a-zA-Z\/\-*]+$/) } }, diff --git a/src/server/api/endpoints/drive/files/show.ts b/src/server/api/endpoints/drive/files/show.ts index 693439974e..39f4b7d2f7 100644 --- a/src/server/api/endpoints/drive/files/show.ts +++ b/src/server/api/endpoints/drive/files/show.ts @@ -91,6 +91,7 @@ export default define(meta, async (ps, user) => { return await DriveFiles.pack(file, { detail: true, + withUser: true, self: true }); }); diff --git a/src/server/api/endpoints/drive/files/upload-from-url.ts b/src/server/api/endpoints/drive/files/upload-from-url.ts index 04e13a05cf..296211c091 100644 --- a/src/server/api/endpoints/drive/files/upload-from-url.ts +++ b/src/server/api/endpoints/drive/files/upload-from-url.ts @@ -4,6 +4,7 @@ import * as ms from 'ms'; import uploadFromUrl from '../../../../../services/drive/upload-from-url'; import define from '../../../define'; import { DriveFiles } from '../../../../../models'; +import { publishMainStream } from '../../../../../services/stream'; export const meta = { desc: { @@ -41,6 +42,16 @@ export const meta = { } }, + comment: { + validator: $.optional.nullable.str, + default: null as any, + }, + + marker: { + validator: $.optional.nullable.str, + default: null as any, + }, + force: { validator: $.optional.bool, default: false, @@ -52,5 +63,12 @@ export const meta = { }; export default define(meta, async (ps, user) => { - return await DriveFiles.pack(await uploadFromUrl(ps.url, user, ps.folderId, null, ps.isSensitive, ps.force), { self: true }); + uploadFromUrl(ps.url, user, ps.folderId, null, ps.isSensitive, ps.force, false, ps.comment).then(file => { + DriveFiles.pack(file, { self: true }).then(packedFile => { + publishMainStream(user.id, 'urlUploadFinished', { + marker: ps.marker, + file: packedFile + }); + }); + }); }); diff --git a/src/server/api/endpoints/notes/create.ts b/src/server/api/endpoints/notes/create.ts index b8c4900af7..6ca22113c7 100644 --- a/src/server/api/endpoints/notes/create.ts +++ b/src/server/api/endpoints/notes/create.ts @@ -150,7 +150,7 @@ export const meta = { }, poll: { - validator: $.optional.obj({ + validator: $.optional.nullable.obj({ choices: $.arr($.str) .unique() .range(2, 10) diff --git a/src/server/api/endpoints/users/search.ts b/src/server/api/endpoints/users/search.ts index 7733b1a6bf..0ec4f3ad02 100644 --- a/src/server/api/endpoints/users/search.ts +++ b/src/server/api/endpoints/users/search.ts @@ -1,6 +1,6 @@ import $ from 'cafy'; import define from '../../define'; -import { Users } from '../../../../models'; +import { UserProfiles, Users } from '../../../../models'; import { User } from '../../../../models/entities/user'; export const meta = { @@ -65,7 +65,7 @@ export const meta = { }; export default define(meta, async (ps, me) => { - const isUsername = ps.localOnly ? Users.validateLocalUsername.ok(ps.query.replace('@', '')) : Users.validateRemoteUsername.ok(ps.query.replace('@', '')); + const isUsername = ps.query.startsWith('@'); let users: User[] = []; @@ -92,6 +92,37 @@ export default define(meta, async (ps, me) => { users = users.concat(otherUsers); } + } else { + const profQuery = UserProfiles.createQueryBuilder('prof') + .select('prof.userId') + .where('prof.userHost IS NULL') + .andWhere('prof.description ilike :query', { query: '%' + ps.query + '%' }); + + users = await Users.createQueryBuilder('user') + .where(`user.id IN (${ profQuery.getQuery() })`) + .setParameters(profQuery.getParameters()) + .andWhere('user.updatedAt IS NOT NULL') + .orderBy('user.updatedAt', 'DESC') + .take(ps.limit!) + .skip(ps.offset) + .getMany(); + + if (users.length < ps.limit! && !ps.localOnly) { + const profQuery2 = UserProfiles.createQueryBuilder('prof') + .select('prof.userId') + .where('prof.userHost IS NOT NULL') + .andWhere('prof.description ilike :query', { query: '%' + ps.query + '%' }); + + const otherUsers = await Users.createQueryBuilder('user') + .where(`user.id IN (${ profQuery2.getQuery() })`) + .setParameters(profQuery2.getParameters()) + .andWhere('user.updatedAt IS NOT NULL') + .orderBy('user.updatedAt', 'DESC') + .take(ps.limit! - users.length) + .getMany(); + + users = users.concat(otherUsers); + } } return await Users.packMany(users, me, { detail: ps.detail }); diff --git a/src/server/web/views/base.pug b/src/server/web/views/base.pug index b0c741e4c2..d3f0106ac1 100644 --- a/src/server/web/views/base.pug +++ b/src/server/web/views/base.pug @@ -32,8 +32,45 @@ html block og meta(property='og:image' content=img) - style - include ./../../../../built/client/assets/style.css + style. + html { + background-color: var(--bg); + color: var(--fg); + } + + #ini { + position: fixed; + z-index: 1; + top: 0; + left: 0; + width: 100%; + height: 100%; + 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; + color: var(--accent); + fill: currentColor; + } + + @keyframes ini { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + script(src=`/assets/app.${version}.js` async defer) script. const theme = localStorage.getItem('theme'); @@ -61,8 +98,7 @@ html document.documentElement.style.backgroundImage = `url(${wallpaper})`; } - //- https://qiita.com/junya/items/3ff380878f26ca447f85 - body(ontouchstart='') + body noscript: p | JavaScriptを有効にしてください br diff --git a/src/services/drive/upload-from-url.ts b/src/services/drive/upload-from-url.ts index 2904ebb30e..96550f7121 100644 --- a/src/services/drive/upload-from-url.ts +++ b/src/services/drive/upload-from-url.ts @@ -16,7 +16,8 @@ export default async ( uri: string | null = null, sensitive = false, force = false, - link = false + link = false, + comment = null ): Promise<DriveFile> => { let name = new URL(url).pathname.split('/').pop() || null; if (name == null || !DriveFiles.validateFileName(name)) { @@ -33,7 +34,7 @@ export default async ( let error; try { - driveFile = await create(user, path, name, null, folderId, force, link, url, uri, sensitive); + driveFile = await create(user, path, name, comment, folderId, force, link, url, uri, sensitive); logger.succ(`Got: ${driveFile.id}`); } catch (e) { error = e; |