refs #884 Add request loading circle in header menu

This commit is contained in:
AkiraFukushima 2019-05-07 22:40:46 +09:00
parent 0eb97c9395
commit 327f621791
6 changed files with 205 additions and 78 deletions

View File

@ -14,7 +14,8 @@ const list: List = {
const state = (): HeaderMenuState => { const state = (): HeaderMenuState => {
return { return {
title: 'Home', title: 'Home',
reload: false reload: false,
loading: false
} }
} }

View File

@ -6,7 +6,8 @@ describe('TimelineSpace/HeaderMenu', () => {
beforeEach(() => { beforeEach(() => {
state = { state = {
title: 'Home', title: 'Home',
reload: false reload: false,
loading: false
} }
}) })
describe('changeReload', () => { describe('changeReload', () => {

View File

@ -0,0 +1,57 @@
<svg class="lds-spinner" width="200px" height="200px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid" style="background: rgba(0, 0, 0, 0) none repeat scroll 0% 0%;"><g transform="rotate(0 50 50)">
<rect x="47" y="3.5" rx="47" ry="3.5" width="6" height="23" fill="#93dbe9">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.9285714285714286s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(25.714285714285715 50 50)">
<rect x="47" y="3.5" rx="47" ry="3.5" width="6" height="23" fill="#93dbe9">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.8571428571428571s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(51.42857142857143 50 50)">
<rect x="47" y="3.5" rx="47" ry="3.5" width="6" height="23" fill="#93dbe9">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.7857142857142857s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(77.14285714285714 50 50)">
<rect x="47" y="3.5" rx="47" ry="3.5" width="6" height="23" fill="#93dbe9">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.7142857142857143s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(102.85714285714286 50 50)">
<rect x="47" y="3.5" rx="47" ry="3.5" width="6" height="23" fill="#93dbe9">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.6428571428571429s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(128.57142857142858 50 50)">
<rect x="47" y="3.5" rx="47" ry="3.5" width="6" height="23" fill="#93dbe9">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.5714285714285714s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(154.28571428571428 50 50)">
<rect x="47" y="3.5" rx="47" ry="3.5" width="6" height="23" fill="#93dbe9">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.5s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(180 50 50)">
<rect x="47" y="3.5" rx="47" ry="3.5" width="6" height="23" fill="#93dbe9">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.42857142857142855s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(205.71428571428572 50 50)">
<rect x="47" y="3.5" rx="47" ry="3.5" width="6" height="23" fill="#93dbe9">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.35714285714285715s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(231.42857142857142 50 50)">
<rect x="47" y="3.5" rx="47" ry="3.5" width="6" height="23" fill="#93dbe9">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.2857142857142857s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(257.14285714285717 50 50)">
<rect x="47" y="3.5" rx="47" ry="3.5" width="6" height="23" fill="#93dbe9">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.21428571428571427s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(282.85714285714283 50 50)">
<rect x="47" y="3.5" rx="47" ry="3.5" width="6" height="23" fill="#93dbe9">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.14285714285714285s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(308.57142857142856 50 50)">
<rect x="47" y="3.5" rx="47" ry="3.5" width="6" height="23" fill="#93dbe9">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.07142857142857142s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(334.2857142857143 50 50)">
<rect x="47" y="3.5" rx="47" ry="3.5" width="6" height="23" fill="#93dbe9">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="0s" repeatCount="indefinite"></animate>
</rect>
</g></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -1,59 +1,66 @@
<template> <template>
<nav id="header_menu" :aria-label="title"> <nav id="header_menu" :aria-label="title">
<div class="channel"> <div class="channel">
<h1>{{ title }}</h1> <h1>{{ title }}</h1>
</div> </div>
<div class="tools"> <div class="tools">
<el-button v-if="!pleroma" type="text" class="action" @click="switchStreaming" :title="$t('header_menu.switch_streaming')"> <img src="../../assets/images/loading-spinner-wide.svg" v-show="loading" class="header-loading" />
<svg :class="useWebsocket ? 'websocket' : 'not-websocket'" width="25" height="18" viewBox="0 0 256 193" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M192.44 144.645h31.78V68.339l-35.805-35.804-22.472 22.472 26.497 26.497v63.14zm31.864 15.931H113.452L86.954 134.08l11.237-11.236 21.885 21.885h45.028l-44.357-44.441 11.32-11.32 44.357 44.358V88.296l-21.801-21.801 11.152-11.153L110.685 0H0l31.696 31.696v.084H97.436l23.227 23.227-33.96 33.96L63.476 65.74V47.712h-31.78v31.193l55.007 55.007L64.314 156.3l35.805 35.805H256l-31.696-31.529z" /></svg> <el-button v-if="!pleroma" type="text" class="action" @click="switchStreaming" :title="$t('header_menu.switch_streaming')">
</el-button> <svg
<el-button type="text" class="action" @click="openNewTootModal" :title="$t('header_menu.new_toot')"> :class="useWebsocket ? 'websocket' : 'not-websocket'"
<icon name="regular/edit" scale="1.1"></icon> width="25"
</el-button> height="18"
<el-button v-show="reloadable()" type="text" class="action" @click="reload" :title="$t('header_menu.reload')"> viewBox="0 0 256 193"
<icon name="sync-alt"></icon> xmlns="http://www.w3.org/2000/svg"
</el-button> preserveAspectRatio="xMidYMid"
<el-popover >
placement="left-start" <path
width="320" d="M192.44 144.645h31.78V68.339l-35.805-35.804-22.472 22.472 26.497 26.497v63.14zm31.864 15.931H113.452L86.954 134.08l11.237-11.236 21.885 21.885h45.028l-44.357-44.441 11.32-11.32 44.357 44.358V88.296l-21.801-21.801 11.152-11.153L110.685 0H0l31.696 31.696v.084H97.436l23.227 23.227-33.96 33.96L63.476 65.74V47.712h-31.78v31.193l55.007 55.007L64.314 156.3l35.805 35.805H256l-31.696-31.529z"
popper-class="theme-popover" />
trigger="click" </svg>
v-model="filterVisible">
<div>
<el-form role="form" label-position="left" label-width="125px" size="medium">
<el-form-item for="filter" :label="$t('header_menu.filter.title')">
<div class="el-input">
<input
id="filter"
class="el-input__inner"
v-model="filter"
:placeholder="$t('header_menu.filter.placeholder')"
v-shortkey.avoid
:aria-label="$t('header_menu.filter.placeholder')"
:title="$t('header_menu.filter.placeholder')"
>
</div>
</el-form-item>
<el-form-item for="show-reblogs" :label="$t('header_menu.filter.show_reblogs')" v-if="extrasFilterable()">
<el-checkbox id="show-reblogs" v-model="showReblogs"></el-checkbox>
</el-form-item>
<el-form-item for="show-replies" :label="$t('header_menu.filter.show_replies')" v-if="extrasFilterable()">
<el-checkbox id="show-replies" v-model="showReplies"></el-checkbox>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="applyFilter(filter)">{{ $t('header_menu.filter.apply') }}</el-button>
</el-form-item>
</el-form>
</div>
<el-button v-show="filterable()" slot="reference" type="text" class="action" :title="$t('header_menu.filter.title')">
<icon name="sliders-h"></icon>
</el-button> </el-button>
</el-popover> <el-button type="text" class="action" @click="openNewTootModal" :title="$t('header_menu.new_toot')">
<el-button type="text" class="action" @click="settings" :title="$t('header_menu.settings')"> <icon name="regular/edit" scale="1.1"></icon>
<icon name="cog" scale="1.1"></icon> </el-button>
</el-button> <el-button v-show="reloadable()" type="text" class="action" @click="reload" :title="$t('header_menu.reload')">
</div> <icon name="sync-alt"></icon>
</nav> </el-button>
<el-popover placement="left-start" width="320" popper-class="theme-popover" trigger="click" v-model="filterVisible">
<div>
<el-form role="form" label-position="left" label-width="125px" size="medium">
<el-form-item for="filter" :label="$t('header_menu.filter.title')">
<div class="el-input">
<input
id="filter"
class="el-input__inner"
v-model="filter"
:placeholder="$t('header_menu.filter.placeholder')"
v-shortkey.avoid
:aria-label="$t('header_menu.filter.placeholder')"
:title="$t('header_menu.filter.placeholder')"
/>
</div>
</el-form-item>
<el-form-item for="show-reblogs" :label="$t('header_menu.filter.show_reblogs')" v-if="extrasFilterable()">
<el-checkbox id="show-reblogs" v-model="showReblogs"></el-checkbox>
</el-form-item>
<el-form-item for="show-replies" :label="$t('header_menu.filter.show_replies')" v-if="extrasFilterable()">
<el-checkbox id="show-replies" v-model="showReplies"></el-checkbox>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="applyFilter(filter)">{{ $t('header_menu.filter.apply') }}</el-button>
</el-form-item>
</el-form>
</div>
<el-button v-show="filterable()" slot="reference" type="text" class="action" :title="$t('header_menu.filter.title')">
<icon name="sliders-h"></icon>
</el-button>
</el-popover>
<el-button type="text" class="action" @click="settings" :title="$t('header_menu.settings')">
<icon name="cog" scale="1.1"></icon>
</el-button>
</div>
</nav>
</template> </template>
<script> <script>
@ -61,7 +68,7 @@ import { mapState } from 'vuex'
export default { export default {
name: 'header-menu', name: 'header-menu',
data () { data() {
return { return {
filter: '', filter: '',
filterVisible: false, filterVisible: false,
@ -71,28 +78,30 @@ export default {
}, },
computed: { computed: {
...mapState('TimelineSpace/HeaderMenu', { ...mapState('TimelineSpace/HeaderMenu', {
title: state => state.title title: state => state.title,
loading: state => state.loading
}), }),
...mapState('TimelineSpace', { ...mapState('TimelineSpace', {
useWebsocket: state => state.useWebsocket, useWebsocket: state => state.useWebsocket,
pleroma: state => state.pleroma pleroma: state => state.pleroma
}) })
}, },
created () { created() {
this.channelName() this.channelName()
this.loadFilter() this.loadFilter()
this.$store.dispatch('TimelineSpace/HeaderMenu/setupLoading')
}, },
watch: { watch: {
'$route': function () { $route: function() {
this.channelName() this.channelName()
this.loadFilter() this.loadFilter()
} }
}, },
methods: { methods: {
id () { id() {
return this.$route.params.id return this.$route.params.id
}, },
channelName () { channelName() {
switch (this.$route.name) { switch (this.$route.name) {
case 'home': case 'home':
this.$store.commit('TimelineSpace/HeaderMenu/updateTitle', this.$t('header_menu.home')) this.$store.commit('TimelineSpace/HeaderMenu/updateTitle', this.$t('header_menu.home'))
@ -139,15 +148,15 @@ export default {
break break
} }
}, },
switchStreaming () { switchStreaming() {
this.$store.dispatch('TimelineSpace/stopStreamings') this.$store.dispatch('TimelineSpace/stopStreamings')
this.$store.commit('TimelineSpace/changeUseWebsocket', !this.useWebsocket) this.$store.commit('TimelineSpace/changeUseWebsocket', !this.useWebsocket)
this.$store.dispatch('TimelineSpace/startStreamings') this.$store.dispatch('TimelineSpace/startStreamings')
}, },
openNewTootModal () { openNewTootModal() {
this.$store.dispatch('TimelineSpace/Modals/NewToot/openModal') this.$store.dispatch('TimelineSpace/Modals/NewToot/openModal')
}, },
reload () { reload() {
switch (this.$route.name) { switch (this.$route.name) {
case 'home': case 'home':
case 'notifications': case 'notifications':
@ -164,7 +173,7 @@ export default {
console.log('Not implemented') console.log('Not implemented')
} }
}, },
reloadable () { reloadable() {
switch (this.$route.name) { switch (this.$route.name) {
case 'home': case 'home':
case 'notifications': case 'notifications':
@ -180,7 +189,7 @@ export default {
return false return false
} }
}, },
loadFilter () { loadFilter() {
switch (this.$route.name) { switch (this.$route.name) {
case 'home': case 'home':
this.filter = this.$store.state.TimelineSpace.Contents.Home.filter this.filter = this.$store.state.TimelineSpace.Contents.Home.filter
@ -215,7 +224,7 @@ export default {
console.log('Not implemented') console.log('Not implemented')
} }
}, },
applyFilter (filter) { applyFilter(filter) {
switch (this.$route.name) { switch (this.$route.name) {
case 'home': case 'home':
this.$store.commit('TimelineSpace/Contents/Home/changeFilter', filter) this.$store.commit('TimelineSpace/Contents/Home/changeFilter', filter)
@ -251,7 +260,7 @@ export default {
} }
this.filterVisible = false this.filterVisible = false
}, },
filterable () { filterable() {
switch (this.$route.name) { switch (this.$route.name) {
case 'home': case 'home':
case 'notifications': case 'notifications':
@ -267,7 +276,7 @@ export default {
return false return false
} }
}, },
extrasFilterable () { extrasFilterable() {
switch (this.$route.name) { switch (this.$route.name) {
case 'home': case 'home':
return true return true
@ -275,7 +284,7 @@ export default {
return false return false
} }
}, },
settings () { settings() {
const url = `/${this.id()}/settings` const url = `/${this.id()}/settings`
this.$router.push(url) this.$router.push(url)
} }
@ -303,6 +312,13 @@ export default {
.tools { .tools {
font-size: 18px; font-size: 18px;
display: flex;
justify-content: flex-end;
align-items: center;
.header-loading {
width: 18px;
}
.action { .action {
color: var(--theme-secondary-color); color: var(--theme-secondary-color);

View File

@ -1,20 +1,24 @@
import Mastodon, { List, Response } from 'megalodon' import Mastodon, { List, Response } from 'megalodon'
import { Module, MutationTree, ActionTree } from 'vuex' import { Module, MutationTree, ActionTree } from 'vuex'
import { RootState } from '@/store' import { RootState } from '@/store'
import AxiosLoading from '@/utils/axiosLoading'
export interface HeaderMenuState { export interface HeaderMenuState {
title: string, title: string
reload: boolean reload: boolean
loading: boolean
} }
const state = (): HeaderMenuState => ({ const state = (): HeaderMenuState => ({
title: 'Home', title: 'Home',
reload: false reload: false,
loading: false
}) })
export const MUTATION_TYPES = { export const MUTATION_TYPES = {
UPDATE_TITLE: 'updateTitle', UPDATE_TITLE: 'updateTitle',
CHANGE_RELOAD: 'changeReload' CHANGE_RELOAD: 'changeReload',
CHANGE_LOADING: 'changeLoading'
} }
const mutations: MutationTree<HeaderMenuState> = { const mutations: MutationTree<HeaderMenuState> = {
@ -23,18 +27,27 @@ const mutations: MutationTree<HeaderMenuState> = {
}, },
[MUTATION_TYPES.CHANGE_RELOAD]: (state, value: boolean) => { [MUTATION_TYPES.CHANGE_RELOAD]: (state, value: boolean) => {
state.reload = value state.reload = value
},
[MUTATION_TYPES.CHANGE_LOADING]: (state, value: boolean) => {
state.loading = value
} }
} }
const actions: ActionTree<HeaderMenuState, RootState> = { const actions: ActionTree<HeaderMenuState, RootState> = {
fetchList: async ({ commit, rootState }, listID: number): Promise<List> => { fetchList: async ({ commit, rootState }, listID: number): Promise<List> => {
const client = new Mastodon( const client = new Mastodon(rootState.TimelineSpace.account.accessToken!, rootState.TimelineSpace.account.baseURL + '/api/v1')
rootState.TimelineSpace.account.accessToken!,
rootState.TimelineSpace.account.baseURL + '/api/v1'
)
const res: Response<List> = await client.get<List>(`/lists/${listID}`) const res: Response<List> = await client.get<List>(`/lists/${listID}`)
commit(MUTATION_TYPES.UPDATE_TITLE, `#${res.data.title}`) commit(MUTATION_TYPES.UPDATE_TITLE, `#${res.data.title}`)
return res.data return res.data
},
setupLoading: ({ commit }) => {
const axiosLoading = new AxiosLoading()
axiosLoading.on('start', (_: number) => {
commit(MUTATION_TYPES.CHANGE_LOADING, true)
})
axiosLoading.on('done', () => {
commit(MUTATION_TYPES.CHANGE_LOADING, false)
})
} }
} }

View File

@ -0,0 +1,39 @@
import axios, { AxiosResponse } from 'axios'
import { EventEmitter } from 'events'
class AxiosLoading extends EventEmitter {
public requestCounter: number
constructor() {
super()
this.requestCounter = 0
this.setupRequest()
this.setupResponse()
}
private setupRequest() {
axios.interceptors.request.use(config => {
this.requestCounter++
this.emit('start', this.requestCounter)
return config
})
}
private setupResponse() {
const response = (response: AxiosResponse) => {
if (--this.requestCounter === 0) {
this.emit('done', {})
}
return response
}
const error = (error: any) => {
if (--this.requestCounter === 0) {
this.emit('done', {})
}
return Promise.reject(error)
}
axios.interceptors.response.use(response, error)
}
}
export default AxiosLoading