Merge branch 'master' of github.com:jaedb/Iris
This commit is contained in:
@ -15,7 +15,7 @@ __version__ = '2.11.3'
|
||||
#
|
||||
# Loads config and gets the party started. Initiates any additional frontends, etc.
|
||||
##
|
||||
class IrisExtension( ext.Extension ):
|
||||
class Extension( ext.Extension ):
|
||||
|
||||
dist_name = 'Mopidy-Iris'
|
||||
ext_name = 'iris'
|
||||
@ -26,9 +26,11 @@ class IrisExtension( ext.Extension ):
|
||||
return config.read(conf_file)
|
||||
|
||||
def get_config_schema(self):
|
||||
schema = super(IrisExtension, self).get_config_schema()
|
||||
schema['debug'] = config.Boolean()
|
||||
schema = config.ConfigSchema(self.ext_name)
|
||||
schema['enabled'] = config.Boolean()
|
||||
schema['pusherport'] = config.String()
|
||||
schema['country'] = config.String()
|
||||
schema['locale'] = config.String()
|
||||
return schema
|
||||
|
||||
def setup(self, registry):
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
[iris]
|
||||
enabled = true
|
||||
debug = false
|
||||
pusherport = 6681
|
||||
pusherport = 6681
|
||||
country = NZ
|
||||
locale = en_NZ
|
||||
@ -189,6 +189,17 @@ class IrisFrontend(pykka.ThreadingActor, CoreListener):
|
||||
def get_spotify_token( self ):
|
||||
return self.spotify_token
|
||||
|
||||
|
||||
# get our config values
|
||||
def get_config( self ):
|
||||
all_config = self.config
|
||||
config = {
|
||||
"spotify_username": all_config['spotify']['username'],
|
||||
"country": all_config['iris']['country'],
|
||||
"locale": all_config['iris']['locale']
|
||||
}
|
||||
return config
|
||||
|
||||
|
||||
##
|
||||
# Get Spotmop version, and check for updates
|
||||
|
||||
@ -188,6 +188,15 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler):
|
||||
{ 'error': 'Could not send to that connection, does not exist' }
|
||||
)
|
||||
|
||||
# fetch our pusher connections
|
||||
elif messageJson['action'] == 'get_config':
|
||||
send_message(
|
||||
self.connectionid,
|
||||
'response',
|
||||
messageJson['request_id'],
|
||||
{ 'config': self.frontend.get_config() }
|
||||
)
|
||||
|
||||
# fetch our pusher connections
|
||||
elif messageJson['action'] == 'get_connections':
|
||||
|
||||
@ -266,7 +275,7 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler):
|
||||
{ 'version': version }
|
||||
)
|
||||
|
||||
# get system version and check for upgrade
|
||||
# perform upgrade
|
||||
elif messageJson['action'] == 'upgrade':
|
||||
version = self.frontend.get_version()
|
||||
upgrade_successful = self.frontend.perform_upgrade()
|
||||
@ -290,7 +299,7 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler):
|
||||
{ 'error': 'Unhandled action' }
|
||||
)
|
||||
|
||||
logger.debug( 'Pusher: Message received from '+ self.connectionid )
|
||||
logger.debug( 'Pusher: Unhandled message received from '+ self.connectionid )
|
||||
|
||||
# connection closed
|
||||
def on_close(self):
|
||||
|
||||
2
setup.py
2
setup.py
@ -37,7 +37,7 @@ setup(
|
||||
],
|
||||
entry_points={
|
||||
'mopidy.ext': [
|
||||
'iris = mopidy_iris:IrisExtension',
|
||||
'iris = mopidy_iris:Extension',
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
2
src/js/bootstrap.js
vendored
2
src/js/bootstrap.js
vendored
@ -49,8 +49,6 @@ var initialState = {
|
||||
},
|
||||
spotify: {
|
||||
connected: false,
|
||||
country: 'NZ',
|
||||
locale: 'en_NZ',
|
||||
me: false
|
||||
},
|
||||
ui: {
|
||||
|
||||
@ -74,7 +74,9 @@ export default class CreatePlaylistModal extends React.Component{
|
||||
<span className="label">Public</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" className="primary centered" disabled={!this.state.submit_enabled}>Save</button>
|
||||
<div className="actions centered-text">
|
||||
<button type="submit" className="primary wide" disabled={!this.state.submit_enabled}>Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -53,7 +53,9 @@ export default class EditPlaylistModal extends React.Component{
|
||||
<span className="label">Public</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" className="primary centered" disabled={!this.state.submit_enabled}>Save</button>
|
||||
<div className="actions centered-text">
|
||||
<button type="submit" className="primary wide" disabled={!this.state.submit_enabled}>Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -24,13 +24,12 @@ export default class EditRadioModal extends React.Component{
|
||||
this.props.spotifyActions.resolveRadioSeeds(this.props.radio)
|
||||
}
|
||||
|
||||
save(){
|
||||
this.props.pusherActions.startRadio(this.state.seeds)
|
||||
this.props.uiActions.closeModal()
|
||||
}
|
||||
|
||||
stop(){
|
||||
this.props.pusherActions.stopRadio()
|
||||
handleSubmit(e){
|
||||
if (this.state.enabled){
|
||||
this.props.pusherActions.startRadio(this.state.seeds)
|
||||
}else{
|
||||
this.props.pusherActions.stopRadio()
|
||||
}
|
||||
this.props.uiActions.closeModal()
|
||||
}
|
||||
|
||||
@ -68,7 +67,7 @@ export default class EditRadioModal extends React.Component{
|
||||
renderSeeds(){
|
||||
var seeds = []
|
||||
|
||||
if (this.state.enabled && this.state.seeds){
|
||||
if (this.state.seeds){
|
||||
for (var i = 0; i < this.state.seeds.length; i++){
|
||||
var uri = this.state.seeds[i]
|
||||
if (uri){
|
||||
@ -98,37 +97,35 @@ export default class EditRadioModal extends React.Component{
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="list">
|
||||
{
|
||||
seeds.map((seed,index) => {
|
||||
return (
|
||||
<div className="list-item" key={seed.uri}>
|
||||
{seed.unresolved ? <span className="grey-text">{seed.uri}</span> : <span>{seed.name}</span> }
|
||||
<span className="grey-text"> ({seed.type})</span>
|
||||
<FontAwesome name="close" className="pull-right destructive" onClick={() => this.removeSeed(seed.uri)} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
<div>
|
||||
<div className="list">
|
||||
{
|
||||
seeds.map((seed,index) => {
|
||||
return (
|
||||
<div className="list-item" key={seed.uri}>
|
||||
{seed.unresolved ? <span className="grey-text">{seed.uri}</span> : <span>{seed.name}</span> }
|
||||
<span className="grey-text"> ({seed.type})</span>
|
||||
<FontAwesome name="close" className="pull-right destructive" onClick={() => this.removeSeed(seed.uri)} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderActions(){
|
||||
if (this.state.enabled){
|
||||
return (
|
||||
<span>
|
||||
<button className="primary wide" onClick={e => this.save()}>Save</button>
|
||||
<button className="destructive wide" onClick={e => this.stop()}>Stop</button>
|
||||
</span>
|
||||
)
|
||||
}else{
|
||||
return (
|
||||
<span>
|
||||
<button className="primary wide" onClick={e => this.save()}>Start</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
renderAddSeeds(){
|
||||
return (
|
||||
<div className="field no-top-margin">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add comma-separated URIs"
|
||||
onChange={e => this.setState({uri: e.target.value})}
|
||||
value={this.state.uri} />
|
||||
<button type="button" className="discrete" onClick={e => this.addSeed()}><FontAwesome name="plus" /></button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(){
|
||||
@ -136,22 +133,23 @@ export default class EditRadioModal extends React.Component{
|
||||
<div>
|
||||
<h4>Edit radio</h4>
|
||||
|
||||
<h3>Current seeds</h3>
|
||||
{this.renderSeeds()}
|
||||
|
||||
<form>
|
||||
<h3>Add seeds</h3>
|
||||
<div className="field">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Comma-separated URIs"
|
||||
onChange={e => this.setState({uri: e.target.value})}
|
||||
value={this.state.uri} />
|
||||
<button className="discrete" onClick={e => this.addSeed()}><FontAwesome name="check" /></button>
|
||||
<form onSubmit={e => this.handleSubmit(e)}>
|
||||
<div className="field checkbox white">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="enabled"
|
||||
checked={ this.state.enabled }
|
||||
onChange={ e => this.setState({ enabled: !this.state.enabled })} />
|
||||
<span className="label">Radio mode enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{this.state.enabled ? this.renderSeeds() : null}
|
||||
{this.state.enabled ? this.renderAddSeeds() : null}
|
||||
|
||||
<div className="actions centered-text">
|
||||
{this.renderActions()}
|
||||
<button type="submit" className="primary wide">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -39,16 +39,17 @@ import LibraryLocalDirectory from './views/library/LibraryLocalDirectory'
|
||||
|
||||
// setup our analytics tracking
|
||||
ReactGA.initialize('UA-64701652-3');
|
||||
function logPageView() {
|
||||
function handleUpdate() {
|
||||
ReactGA.set({ page: window.location.hash })
|
||||
ReactGA.pageview(window.location.hash)
|
||||
$(window).scrollTop(0)
|
||||
}
|
||||
|
||||
global.baseURL = '/'
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<Router history={hashHistory} onUpdate={logPageView}>
|
||||
<Router history={hashHistory} onUpdate={handleUpdate}>
|
||||
<Route path={global.baseURL} component={App}>
|
||||
|
||||
<IndexRoute component={Queue} />
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
|
||||
var pusherActions = require('./actions.js')
|
||||
var uiActions = require('../ui/actions.js')
|
||||
var helpers = require('../../helpers.js')
|
||||
var uiActions = require('../ui/actions.js')
|
||||
var pusherActions = require('./actions.js')
|
||||
var spotifyActions = require('../spotify/actions.js')
|
||||
|
||||
const PusherMiddleware = (function(){
|
||||
|
||||
@ -103,12 +104,6 @@ const PusherMiddleware = (function(){
|
||||
socket.onopen = () => {
|
||||
store.dispatch({ type: 'PUSHER_CONNECTED', connection: connection });
|
||||
store.dispatch({ type: 'PUSHER_SET_USERNAME', username: connection.username });
|
||||
request({ action: 'get_radio' })
|
||||
.then(
|
||||
response => {
|
||||
store.dispatch({ type: 'RADIO', data: response.data })
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
socket.onmessage = (message) => {
|
||||
@ -119,10 +114,29 @@ const PusherMiddleware = (function(){
|
||||
break;
|
||||
|
||||
case 'PUSHER_CONNECTED':
|
||||
request({ action: 'get_config' })
|
||||
.then(
|
||||
response => {
|
||||
store.dispatch({ type: 'CONFIG', config: response.data.config })
|
||||
if (response.data.config.spotify_username){
|
||||
store.dispatch(spotifyActions.getUser('spotify:user:'+response.data.config.spotify_username))
|
||||
}
|
||||
var spotify = store.getState().spotify
|
||||
if (!spotify.country || !spotify.locale){
|
||||
store.dispatch({ type: 'SPOTIFY_SET_CONFIG', config: response.data.config })
|
||||
}
|
||||
}
|
||||
)
|
||||
request({ action: 'get_version' })
|
||||
.then(
|
||||
response => {
|
||||
store.dispatch({ type: 'VERSION', data: response.data })
|
||||
store.dispatch({ type: 'VERSION', version: response.data.version })
|
||||
}
|
||||
)
|
||||
request({ action: 'get_radio' })
|
||||
.then(
|
||||
response => {
|
||||
store.dispatch({ type: 'RADIO', radio: response.data.radio })
|
||||
}
|
||||
)
|
||||
return next(action);
|
||||
@ -141,7 +155,7 @@ const PusherMiddleware = (function(){
|
||||
}else{
|
||||
store.dispatch( uiActions.createNotification('Upgrade failed, please upgrade manually','bad') )
|
||||
}
|
||||
store.dispatch({ type: 'VERSION', data: response.data })
|
||||
store.dispatch({ type: 'VERSION', version: response.data.version })
|
||||
}
|
||||
)
|
||||
return next(action);
|
||||
|
||||
@ -39,7 +39,7 @@ export default function reducer(pusher = {}, action){
|
||||
|
||||
case 'VERSION':
|
||||
return Object.assign({}, pusher, {
|
||||
version: action.data.version,
|
||||
version: action.version,
|
||||
upgrading: false
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
|
||||
var uiActions = require('../../services/ui/actions.js')
|
||||
var helpers = require('../../helpers.js')
|
||||
|
||||
/**
|
||||
@ -39,6 +40,7 @@ const sendRequest = ( dispatch, getState, endpoint, method = 'GET', data = false
|
||||
},
|
||||
(xhr, status, error) => {
|
||||
console.error( endpoint+' failed', xhr.responseText)
|
||||
dispatch(uiActions.createNotification(xhr.responseText,'bad'))
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
@ -98,6 +100,7 @@ function refreshToken( dispatch, getState ){
|
||||
},
|
||||
error => {
|
||||
dispatch({ type: 'SPOTIFY_DISCONNECTED' })
|
||||
dispatch(uiActions.createNotification('Could not refresh token','bad'))
|
||||
console.error('Could not refresh token', error)
|
||||
reject(error)
|
||||
}
|
||||
@ -124,6 +127,7 @@ function refreshToken( dispatch, getState ){
|
||||
},
|
||||
error => {
|
||||
dispatch({ type: 'SPOTIFY_DISCONNECTED' })
|
||||
dispatch(uiActions.createNotification('Could not refresh token','bad'))
|
||||
console.error('Could not refresh token', error)
|
||||
reject(error)
|
||||
}
|
||||
|
||||
@ -93,8 +93,8 @@ const SpotifyMiddleware = (function(){
|
||||
next(action)
|
||||
|
||||
// only resolve if radio is enabled
|
||||
if( action.data.radio.enabled ){
|
||||
store.dispatch(spotifyActions.resolveRadioSeeds(action.data.radio))
|
||||
if( action.radio.enabled ){
|
||||
store.dispatch(spotifyActions.resolveRadioSeeds(action.radio))
|
||||
}
|
||||
break
|
||||
|
||||
|
||||
@ -12,6 +12,9 @@ export default function reducer(spotify = {}, action){
|
||||
case 'SPOTIFY_DISCONNECTED':
|
||||
return Object.assign({}, spotify, { connected: false, connecting: false })
|
||||
|
||||
case 'SPOTIFY_SET_CONFIG':
|
||||
return Object.assign({}, spotify, action.config)
|
||||
|
||||
case 'PUSHER_SPOTIFY_TOKEN':
|
||||
if( spotify.authorized ) return spotify;
|
||||
return Object.assign({}, spotify, {
|
||||
|
||||
@ -209,9 +209,9 @@ const UIMiddleware = (function(){
|
||||
next(action)
|
||||
break
|
||||
|
||||
case 'PUSHER_VERSION':
|
||||
if( action.data.version.upgrade_available )
|
||||
store.dispatch( uiActions.createNotification( 'Version '+action.data.version.latest+' is available. See settings to upgrade.' ) )
|
||||
case 'VERSION':
|
||||
if( action.version.upgrade_available )
|
||||
store.dispatch( uiActions.createNotification( 'Version '+action.version.latest+' is available. See settings to upgrade.' ) )
|
||||
next( action )
|
||||
break
|
||||
|
||||
|
||||
@ -13,6 +13,9 @@ export default function reducer(ui = {}, action){
|
||||
case 'UI_SET':
|
||||
return Object.assign({}, ui, action.data)
|
||||
|
||||
case 'CONFIG':
|
||||
return Object.assign({}, ui, { config: action.config })
|
||||
|
||||
case 'TOGGLE_SIDEBAR':
|
||||
var new_state = !ui.sidebar_open
|
||||
if( typeof(action.new_state) !== 'undefined' ) new_state = action.new_state
|
||||
@ -118,7 +121,7 @@ export default function reducer(ui = {}, action){
|
||||
|
||||
case 'RADIO':
|
||||
case 'START_RADIO':
|
||||
return Object.assign({}, ui, { seeds_resolved: false }, { radio: action.data.radio })
|
||||
return Object.assign({}, ui, { seeds_resolved: false }, { radio: action.radio })
|
||||
|
||||
case 'RADIO_SEEDS_RESOLVED':
|
||||
var radio = Object.assign({}, ui.radio, { resolved_seeds: action.resolved_seeds })
|
||||
|
||||
@ -34,7 +34,8 @@ class Settings extends React.Component{
|
||||
|
||||
resetAllSettings(){
|
||||
localStorage.clear();
|
||||
window.location.reload(true);
|
||||
window.location = '#'
|
||||
window.location.reload(true)
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -80,21 +81,33 @@ class Settings extends React.Component{
|
||||
}
|
||||
|
||||
renderSpotifyUser(){
|
||||
if( this.props.spotify.me && this.props.spotify.authorized ){
|
||||
|
||||
var user = null
|
||||
if (this.props.spotify.me && this.props.spotify.authorized){
|
||||
user = this.props.spotify.me
|
||||
} else if (this.props.ui.config && this.props.ui.config.spotify_username){
|
||||
if (this.props.ui.users && typeof(this.props.ui.users['spotify:user:'+this.props.ui.config.spotify_username]) !== 'undefined'){
|
||||
user = this.props.ui.users['spotify:user:'+this.props.ui.config.spotify_username]
|
||||
}
|
||||
}
|
||||
|
||||
if (user){
|
||||
return (
|
||||
<Link className="user" to={global.baseURL+'user/'+this.props.spotify.me.uri}>
|
||||
<Thumbnail circle={true} size="small" images={this.props.spotify.me.images} />
|
||||
<Link className="user" to={global.baseURL+'user/'+user.uri}>
|
||||
<Thumbnail circle={true} size="small" images={user.images} />
|
||||
<span className="user-name">
|
||||
{ this.props.spotify.me.display_name ? this.props.spotify.me.display_name : this.props.spotify.me.id }
|
||||
{user.display_name ? user.display_name : user.username}
|
||||
{!this.props.spotify.authorized ? <span className="grey-text"> (As defined in config file)</span> : null}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}else{
|
||||
} else {
|
||||
return (
|
||||
<Link className="user">
|
||||
<Thumbnail circle={true} size="small" />
|
||||
<span className="user-name">
|
||||
Default user <span className="grey-text">(As defined in mopidy.config)</span>
|
||||
Default user
|
||||
<span className="grey-text"> (As defined in config file)</span>
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
|
||||
@ -32,7 +32,8 @@ main {
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 14px 10px;
|
||||
padding: 13px 10px;
|
||||
line-height: 24px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
@ -133,7 +133,6 @@
|
||||
|
||||
&.edit_radio {
|
||||
form {
|
||||
padding-top: 60px;
|
||||
.field {
|
||||
position: relative;
|
||||
margin-top: 10px;
|
||||
@ -141,7 +140,11 @@
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 0;
|
||||
padding: 10px 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
input[type="text"]{
|
||||
font-size: 14px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
z-index: 100;
|
||||
z-index: 99;
|
||||
pointer-events: none;
|
||||
|
||||
.notification {
|
||||
display: inline-block;
|
||||
@ -15,6 +16,7 @@
|
||||
padding: 12px 32px 12px 18px;
|
||||
margin: 0 0.5px;
|
||||
color: #FFFFFF;
|
||||
pointer-events: all;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 50px;
|
||||
|
||||
@ -115,6 +115,8 @@ h4 {
|
||||
.no-bottom-padding { padding-bottom: 0 !important; }
|
||||
.no-right-padding { padding-right: 0 !important; }
|
||||
.no-left-padding { padding-left: 0 !important; }
|
||||
.no-top-margin { margin-top: 0 !important; }
|
||||
.no-bottom-margin { margin-bottom: 0 !important; }
|
||||
|
||||
.top-padding { padding-top: 20px; }
|
||||
.bottom-padding { padding-bottom: 20px; }
|
||||
|
||||
@ -47,6 +47,10 @@ input[type="submit"] {
|
||||
|
||||
&.large {
|
||||
padding: 16px 24px;
|
||||
|
||||
@include responsive( $bp_medium ){
|
||||
padding: 10px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&.primary {
|
||||
|
||||
@ -84,9 +84,7 @@
|
||||
|
||||
@include responsive( $bp_medium ){
|
||||
.intro {
|
||||
|
||||
padding: 50px 30px 0 30px;
|
||||
margin: 0 0 140px 0;
|
||||
padding: 50px 30px 60px 30px;
|
||||
|
||||
.parallax {
|
||||
height: 100%;
|
||||
@ -107,26 +105,48 @@
|
||||
|
||||
.heading-wrapper {
|
||||
height: auto;
|
||||
position: static;
|
||||
|
||||
.heading,
|
||||
.sub-views {
|
||||
position: relative;
|
||||
.heading {
|
||||
position: static;
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
|
||||
h1 {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: 15px 0;
|
||||
}
|
||||
|
||||
.sub-views {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.details-wrapper {
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
position: relative;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 30px;
|
||||
background: $faint_grey;
|
||||
z-index: 2;
|
||||
padding: 20px 0;
|
||||
z-index: 3;
|
||||
|
||||
.actions {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
float: none;
|
||||
|
||||
button {
|
||||
margin: 0 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
clear: both;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user