Merge branch 'feature/discover'

This commit is contained in:
James Barnsley
2017-05-16 12:40:55 +12:00
20 changed files with 810 additions and 64 deletions

BIN
Screenshots/discover.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

3
src/js/bootstrap.js vendored
View File

@ -49,7 +49,8 @@ var initialState = {
},
spotify: {
connected: false,
me: false
me: false,
autocomplete_results: {}
},
ui: {
current_tracklist: [],

137
src/js/components/AddSeedField.js Executable file
View File

@ -0,0 +1,137 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import { Link, hashHistory } from 'react-router'
import ArtistSentence from './ArtistSentence'
import * as helpers from '../helpers'
import * as spotifyActions from '../services/spotify/actions'
class AddSeedField extends React.Component{
constructor(props) {
super(props)
this.state = {
value: ''
}
this.id = helpers.generateGuid()
this.timer = null
this.handleClick = this.handleClick.bind(this)
}
componentDidMount(){
window.addEventListener("click", this.handleClick, false)
if (!this.props.genres){
this.props.spotifyActions.getGenres()
}
}
componentWillUnmount(){
window.removeEventListener("click", this.handleClick, false)
}
handleClick(e){
if ($(e.target).closest('.add-seed-field').length <= 0){
this.props.spotifyActions.clearAutocompleteResults(this.id)
}
}
handleChange(e,value){
var self = this
// update our local state
this.setState({value: value})
// start a timer to perform the actual search
// this provides a wee delay between key presses to avoid request spamming
clearTimeout(this.timer)
this.timer = setTimeout(
function(){
self.setState({searching: true})
self.props.spotifyActions.getAutocompleteResults(self.id,value,['artist','track','genre'])
},
500
)
}
handleSelect(e,item){
this.setState({value: ''})
this.props.onSelect(e,item)
this.props.spotifyActions.clearAutocompleteResults(this.id)
}
results(){
if (typeof(this.props.results) === 'undefined'){
return null
} else if (typeof(this.props.results[this.id]) === 'undefined'){
return null
} else {
return this.props.results[this.id]
}
}
renderResults(type){
var results = this.results()
if (!results || typeof(results[type]) === 'undefined' || results[type].length <= 0) return null
// only show the first 3
var items = results[type].slice(0,3)
return (
<div className="type">
<h4 className="grey-text">{type}</h4>
{
items.map(item => {
return (
<div className="result" key={item.uri} onClick={e => this.handleSelect(e,item)}>
{item.name}
{type == 'tracks' ? <span className="grey-text"> <ArtistSentence artists={item.artists} nolinks /></span> : null}
</div>
)
})
}
</div>
)
}
render(){
var className = "field autocomplete-field add-seed-field"
if (this.results() && this.results().loading){
className += " loading"
}
return (
<div className={className}>
<div className="input">
<input
type="text"
value={this.state.value}
onChange={e => this.handleChange(e,e.target.value)}
placeholder={this.props.placeholder ? this.props.placeholder : "Start typing..."} />
</div>
<div className="results">
{this.renderResults('artists')}
{this.renderResults('tracks')}
{this.renderResults('genres')}
</div>
</div>
);
}
}
const mapStateToProps = (state, ownProps) => {
return {
genres: (state.spotify.genres ? state.spotify.genres : null),
results: (state.spotify.autocomplete_results ? state.spotify.autocomplete_results : {})
}
}
const mapDispatchToProps = (dispatch) => {
return {
spotifyActions: bindActionCreators(spotifyActions, dispatch)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(AddSeedField)

View File

@ -25,10 +25,10 @@ export default class ArtistSentence extends React.Component{
}
if (!artist.name){
var content = <span>-</span>
} else if (artist.uri){
var content = <Link className="artist" to={global.baseURL+'artist/'+artist.uri}>{ artist.name }</Link>
}else{
} else if (!artist.uri || this.props.nolinks){
var content = <span>{ artist.name }</span>
}else{
var content = <Link className="artist" to={global.baseURL+'artist/'+artist.uri}>{ artist.name }</Link>
}
return (
<span key={'index_'+artist.uri}>

View File

@ -31,8 +31,8 @@ export default class LazyLoadListener extends React.Component{
render(){
return (
<div className={this.state.loading ? "lazy-loader loading" : "lazy-loader"}>
<div className="loader"></div>
<div className="lazy-loader body-loader">
{this.state.loading ? <div className="loader"></div> : null}
</div>
)
}

View File

@ -39,7 +39,7 @@ class Sidebar extends React.Component{
<section>
<title>Discover</title>
<Link activeClassName="active" disabled={!this.props.spotify_authorized} to={this.props.spotify_authorized ? global.baseURL+"discover" : null}>
<Link activeClassName="active" to={global.baseURL+"discover"}>
<Icon name="compass" className="white" />
Discover
</Link>

View File

@ -203,6 +203,12 @@ export let getFromUri = function(element,uri){
return exploded[2]
}
break
case 'genreid':
if (exploded[1] == 'genre'){
return exploded[2]
}
break
}
return null
}
@ -225,6 +231,7 @@ export let uriType = function( uri ){
case 'album':
case 'playlist':
case 'search':
case 'genre':
return exploded[1]
break
@ -435,4 +442,26 @@ export let setWindowTitle = function (track = false, play_state = false){
}
document.title = title
}
/**
* Detect if an item is in the loading queue. We simply loop all load items to
* see if any items contain our searched key.
*
* TODO: Explore performance of this
*
* @param load_queue = obj (passed from store)
* @param key = string (the string to lookup)
* @return boolean
**/
export let isLoading = function(load_queue = [], key = ''){
for (var load_queue_key in load_queue) {
if (load_queue.hasOwnProperty(load_queue_key)){
if (load_queue[load_queue_key] == key){
return true
}
}
}
return false
}

View File

@ -34,7 +34,13 @@ const sendRequest = ( dispatch, getState, endpoint, method = 'GET', data = false
}
// only if we've got data do we add it to the request (this prevents appending of "&false" to the URL)
if (data) config.data = JSON.stringify(data)
if (data){
if (typeof(data) === 'string'){
config.data = data
} else {
config.data = JSON.stringify(data)
}
}
// add reference to loader queue
var loader_key = helpers.generateGuid()
@ -406,8 +412,8 @@ export function getURL( url, action_name, key = false ){
}
}
export function getSearchResults( query, type = 'album,artist,playlist,track', limit = 50, offset = 0 ){
return (dispatch, getState) => {
export function getSearchResults(query, type = 'album,artist,playlist,track', limit = 50, offset = 0){
return (dispatch, getState) => {
var url = 'search?q='+query
url += '&type='+type
@ -456,7 +462,59 @@ export function getSearchResults( query, type = 'album,artist,playlist,track', l
tracks_more: response.tracks.next
});
});
}
}
}
export function getAutocompleteResults(field_id, query, types = ['album','artist','playlist','track']){
return (dispatch, getState) => {
dispatch({type: 'SPOTIFY_AUTOCOMPLETE_LOADING', field_id: field_id})
var genre_included = types.includes('genre')
if (genre_included){
var index = types.indexOf('genre')
types.splice(index,1)
}
var endpoint = 'search?q='+query
endpoint += '&type='+types.join(',')
endpoint += '&country='+getState().spotify.country
sendRequest(dispatch, getState, endpoint)
.then(response => {
var genres = []
if (genre_included){
var available_genres = getState().spotify.genres
for (var i = 0; i < available_genres.length; i++){
if (available_genres[i].includes(query)){
var genre = available_genres[i]
genres.push({
name: (genre.charAt(0).toUpperCase()+genre.slice(1)).replace('-',' '),
uri: 'spotify:genre:'+genre
})
}
}
}
dispatch({
type: 'SPOTIFY_AUTOCOMPLETE_LOADED',
field_id: field_id,
results: {
artists: (response.artists ? response.artists.items : []),
albums: (response.albums ? response.albums.items : []),
playlists: (response.playlists ? response.playlists.items : []),
tracks: (response.tracks ? response.tracks.items : []),
genres: genres
}
});
})
}
}
export function clearAutocompleteResults(field_id = null){
return {
type: 'SPOTIFY_AUTOCOMPLETE_CLEAR',
field_id: field_id
}
}
export function following(uri, method = 'GET'){
@ -568,43 +626,142 @@ export function resolveRadioSeeds( radio ){
/**
* Get our recommendations
* This is based off our 'favorites' and then we use those as seeds
* Get my favorites
*
* @param uri string
**/
export function getDiscover(){
export function getFavorites(limit = 50, term = 'long_term'){
return (dispatch, getState) => {
dispatch({ type: 'SPOTIFY_DISCOVER_LOADED', data: false })
dispatch({type: 'SPOTIFY_FAVORITES_LOADED', artists: [], tracks: []})
// get favorite tracks
sendRequest( dispatch, getState, 'me/top/tracks?limit=50&time_range=long_term' )
$.when(
sendRequest(dispatch, getState, 'me/top/artists?limit='+limit+'&time_range='+term),
sendRequest(dispatch, getState, 'me/top/tracks?limit='+limit+'&time_range='+term)
).then((artists_response, tracks_response) => {
dispatch({
type: 'SPOTIFY_FAVORITES_LOADED',
artists: artists_response.items,
tracks: tracks_response.items
});
})
}
}
/**
* Get our recommendations
* This is based off our 'favorites' and then we use those as seeds
*
* @param uris = array of artist or track URIs or a genre string
**/
export function getRecommendations(uris = [], limit = 20){
return (dispatch, getState) => {
dispatch({type: 'SPOTIFY_RECOMMENDATIONS_LOADED', tracks: []})
// build our starting point
var artists_ids = []
var tracks_ids = []
var genres = []
for (var i = 0; i < uris.length; i++){
var uri = uris[i]
switch (helpers.uriType(uri)){
case 'artist':
artists_ids.push(helpers.getFromUri('artistid',uri))
break
case 'track':
tracks_ids.push(helpers.getFromUri('trackid',uri))
break
case 'genre':
genres.push(helpers.getFromUri('genreid',uri))
break
case 'default':
genres.push(uri)
break
}
}
// construct our endpoint URL with all the appropriate arguments
var data = 'seed_artists='+artists_ids.join(',')
data += '&seed_tracks='+tracks_ids.join(',')
data += '&seed_genres='+genres.join(',')
data += '&limit='+limit
sendRequest(dispatch, getState, 'recommendations', 'GET', data)
.then( response => {
var favorite_tracks_uris = helpers.asURIs(response.items)
favorite_tracks_uris = favorite_tracks_uris.sort(() => .5 - Math.random())
favorite_tracks_uris = favorite_tracks_uris.slice(0,10)
// We only get simple artist objects, so we need to
// get the full object. We'll add URIs to our recommendations
// anyway so we can proceed in the meantime
var artists_uris = []
if (response.tracks.length > artists_ids.length && response.tracks.length > 10){
while (artists_uris.length < 5){
var random_index = Math.round(Math.random() * (response.tracks.length - 1))
var artist = response.tracks[random_index].artists[0]
for (var i = 0; i < favorite_tracks_uris.length; i++){
var seed_id = helpers.getFromUri('trackid',favorite_tracks_uris[i] )
$.when(
sendRequest( dispatch, getState, 'recommendations?seed_tracks='+seed_id ),
sendRequest( dispatch, getState, 'tracks/'+seed_id )
).then( ( tracks_response, seed_response ) => {
dispatch({
type: 'SPOTIFY_DISCOVER_LOADED',
data: {
tracks: tracks_response.tracks,
seed: seed_response
}
});
});
// Make sure this artist is not already in our sample, and
// is not one of the seeds
if (!artists_uris.includes(artist.uri) && !artists_ids.includes(artist.id)){
artists_uris.push(artist.uri)
dispatch(getArtist(artist.uri))
}
}
}
// Copy already loaded albums into array
var albums = []
var albums_uris = []
if (response.tracks.length > 10){
while (albums.length < 5){
var random_index = Math.round(Math.random() * (response.tracks.length - 1))
var album = response.tracks[random_index].album
// Make sure this album is not already in our sample
if (!albums_uris.includes(album.uri)){
albums_uris.push(album.uri)
albums.push(album)
}
}
}
// Officially add albums to index
dispatch({
type: 'ALBUMS_LOADED',
albums: albums
})
dispatch({
type: 'SPOTIFY_RECOMMENDATIONS_LOADED',
tracks: response.tracks,
artists_uris: artists_uris,
albums_uris: helpers.asURIs(albums)
})
})
}
}
/**
* Get all the available genres
*
* @param uri string
**/
export function getGenres(){
return (dispatch, getState) => {
sendRequest(dispatch, getState, 'recommendations/available-genre-seeds')
.then( response => {
dispatch({
type: 'SPOTIFY_GENRES_LOADED',
genres: response.genres
});
})
}
}

View File

@ -179,6 +179,63 @@ export default function reducer(spotify = {}, action){
if( !action.data ) return Object.assign({}, spotify, { discover: [] })
return Object.assign({}, spotify, { discover: [...spotify.discover, ...[action.data]] })
case 'SPOTIFY_GENRES_LOADED':
return Object.assign({}, spotify, {genres: action.genres})
case 'SPOTIFY_RECOMMENDATIONS_LOADED':
return Object.assign(
{},
spotify,
{
recommendations: {
artists_uris: action.artists_uris,
albums_uris: action.albums_uris,
tracks: action.tracks
}
})
case 'SPOTIFY_FAVORITES_LOADED':
return Object.assign(
{},
spotify,
{
favorite_artists: action.artists,
favorite_tracks: action.tracks
})
case 'SPOTIFY_AUTOCOMPLETE_LOADING':
var autocomplete_results = spotify.autocomplete_results
autocomplete_results[action.field_id] = {loading: true}
return Object.assign(
{},
spotify,
{
autocomplete_results: autocomplete_results
})
case 'SPOTIFY_AUTOCOMPLETE_LOADED':
var autocomplete_results = spotify.autocomplete_results
autocomplete_results[action.field_id] = action.results
autocomplete_results[action.field_id].loading = false
return Object.assign(
{},
spotify,
{
autocomplete_results: autocomplete_results
})
case 'SPOTIFY_AUTOCOMPLETE_CLEAR':
var autocomplete_results = spotify.autocomplete_results
if (typeof(autocomplete_results[action.field_id]) !== 'undefined'){
delete autocomplete_results[action.field_id]
}
return Object.assign(
{},
spotify,
{
autocomplete_results: autocomplete_results
})
default:
return spotify
}

View File

@ -2,10 +2,15 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import FontAwesome from 'react-fontawesome'
import Header from '../../components/Header'
import GridSlider from '../../components/GridSlider'
import SidebarToggleButton from '../../components/SidebarToggleButton'
import ArtistSentence from '../../components/ArtistSentence'
import ArtistGrid from '../../components/ArtistGrid'
import AlbumGrid from '../../components/AlbumGrid'
import TrackList from '../../components/TrackList'
import Parallax from '../../components/Parallax'
import AddSeedField from '../../components/AddSeedField'
import * as helpers from '../../helpers'
import * as spotifyActions from '../../services/spotify/actions'
@ -13,35 +18,155 @@ import * as spotifyActions from '../../services/spotify/actions'
class Discover extends React.Component{
constructor(props) {
super(props);
super(props)
this._autocomplete_timer = false
this.state = {
seeds: [
{
uri: 'spotify:genre:chill',
name: 'Chill'
}
],
add_seed: '',
adding_seed: false
}
}
componentDidMount(){
this.props.spotifyActions.getDiscover()
if (this.props.authorized){
this.props.spotifyActions.getFavorites()
} else {
this.getRecommendations()
}
}
renderRecommendations(){
if (!this.props.discover || this.props.discover.length <= 0) return null
componentWillReceiveProps(newProps, newState){
// When we've loaded favorite_artists.
// This indirectly listens for when the action has
// loaded new data.
if (this.props.favorite_artists.length <= 0 && newProps.favorite_artists.length > 0){
var initial_seeds = newProps.favorite_artists.sort(() => .5 - Math.random())
initial_seeds = initial_seeds.slice(0,2)
this.setState({seeds: initial_seeds})
this.getRecommendations(initial_seeds)
}
}
getRecommendations(seeds = this.state.seeds){
if (seeds.length > 0){
var uris = helpers.asURIs(seeds)
this.props.spotifyActions.getRecommendations(uris, 50)
}
}
removeSeed(index){
var seeds = this.state.seeds
seeds.splice(index,1)
this.setState({seeds: seeds})
this.getRecommendations(seeds)
}
handleSelect(e,item){
var seeds = this.state.seeds
seeds.push(item)
this.setState({seeds: seeds})
this.getRecommendations(seeds)
}
renderSeeds(){
return (
<section className="recommendations">
<div className="seeds">
{
this.props.discover.map(
(discover, index) => {
var title = <h4>Because you listened to <ArtistSentence artists={discover.seed.artists} /></h4>
return <GridSlider title={title} tracks={discover.tracks} key={index} />
this.state.seeds.map((seed,index) => {
var type = helpers.uriType(seed.uri)
if (!type){
type = 'genre'
}
)
return (
<span className="seed" key={seed.uri}>
{seed.name}
<span className="type">({type})</span>
<FontAwesome name="close" className="remove" onClick={() => this.removeSeed(index)} />
</span>
)
})
}
</section>
<AddSeedField onSelect={(e,item) => this.handleSelect(e,item)} />
</div>
)
}
renderResults(){
if (helpers.isLoading(this.props.load_queue, 'spotify_recommendations')){
return (
<div className="body-loader">
<div className="loader"></div>
</div>
)
}
if (!this.props.recommendations || typeof(this.props.recommendations.albums_uris) === 'undefined' || typeof(this.props.recommendations.artists_uris) === 'undefined'){
return null
}
var albums = []
if (this.props.recommendations.albums_uris && this.props.albums){
for (var i = 0; i < this.props.recommendations.albums_uris.length; i++){
var uri = this.props.recommendations.albums_uris[i]
if (this.props.albums.hasOwnProperty(uri)){
albums.push(this.props.albums[uri])
}
}
}
var artists = []
if (this.props.recommendations.artists_uris && this.props.artists){
for (var i = 0; i < this.props.recommendations.artists_uris.length; i++){
var uri = this.props.recommendations.artists_uris[i]
if (this.props.artists.hasOwnProperty(uri)){
artists.push(this.props.artists[uri])
}
}
}
return (
<div className="recommendations-results">
<section className="grid-wrapper">
<h4>Artists</h4>
<ArtistGrid artists={artists} />
</section>
<section className="grid-wrapper">
<h4>Albums</h4>
<AlbumGrid albums={albums} />
</section>
<section className="list-wrapper">
<h4 className="left-padding">Tracks</h4>
{this.props.recommendations.tracks ? <TrackList className="discover-track-list" uri="iris:discover" tracks={this.props.recommendations.tracks} /> : null}
</section>
</div>
)
}
render(){
return (
<div className="view discover-view">
<Header icon="compass" title="Discover" />
{ this.renderRecommendations() }
<SidebarToggleButton />
<div className="intro">
<Parallax image="/iris/assets/backgrounds/discover.jpg" />
<div className="liner">
<h1>Discover new music</h1>
<h3>
Add seeds below to build your sound. Let's start with
{this.props.authorized ? " two of your favorite artists" : " the chill genre"}.
</h3>
{this.renderSeeds()}
</div>
</div>
{this.renderResults()}
</div>
)
}
@ -56,8 +181,14 @@ class Discover extends React.Component{
const mapStateToProps = (state, ownProps) => {
return {
albums: state.ui.albums,
artists: state.ui.artists,
authorized: state.spotify.authorized,
discover: state.spotify.discover
load_queue: state.ui.load_queue,
quick_search_results: (state.spotify.quick_search_results ? state.spotify.quick_search_results : {artists: [], tracks: []}),
recommendations: (state.spotify.recommendations ? state.spotify.recommendations : {}),
favorite_artists: (state.spotify.favorite_artists ? state.spotify.favorite_artists : []),
favorite_tracks: (state.spotify.favorite_tracks ? state.spotify.favorite_tracks : [])
}
}

View File

@ -7,6 +7,7 @@
@import 'global/forms';
@import 'components/context-menu';
@import 'components/loader';
@import 'components/modal';
@import 'components/dragger';
@import 'components/images';
@ -22,6 +23,7 @@
@import 'components/header';
@import 'components/notifications';
@import 'components/dropdown-field';
@import 'components/autocomplete-field';
@import 'components/debug';
@import 'views/artist';

View File

@ -0,0 +1,74 @@
.autocomplete-field {
position: relative;
.input {
width: 100%;
}
&.loading {
.input {
position: relative;
&:after {
position: absolute;
display: block;
content: '';
background: rgba(255,255,255,0.2);
opacity: 1;
animation: slideloader 1s infinite;
bottom: 0;
left: 0;
right: 0;
height: 4px;
z-index: 1;
}
}
}
.results {
position: absolute;
top: 37px;
left: 0;
right: 0;
z-index: 97;
background: $dark_grey;
color: $white;
.type {
&:not(:first-child){
padding-top: 5px;
}
h4 {
padding: 10px 14px 4px;
margin: 0;
font-size: 12px;
}
.result {
display: block;
padding: 10px 14px;
border-top: 1px solid lighten($dark_grey, 10%);
cursor: pointer;
&:hover {
background: lighten($dark_grey, 10%);
}
}
}
.all-results {
display: block;
padding: 10px 14px;
border-top: 1px solid lighten($dark_grey, 10%);
cursor: pointer;
font-weight: bold;
&:hover {
background: lighten($dark_grey, 10%);
}
}
}
}

View File

@ -4,6 +4,7 @@
position: fixed;
top: 0;
right: 0;
z-index: 96;
padding: 10px;
background: rgba(0,0,0,0.5);
color: #FFFFFF;

View File

@ -39,6 +39,17 @@
opacity: 0.75;
}
}
&:active,
&:focus {
.thumbnail {
.image {
margin: 2%;
padding-bottom: 96%;
width: 96%;
}
}
}
}
&.category-grid {

View File

@ -0,0 +1,22 @@
.body-loader {
padding: 50px 0;
.loader {
@include spin();
margin: 0 auto;
width: 30px;
height: 30px;
border: {
radius: 100%;
style: solid;
color: $mid_grey;
width: 2px;
}
border-top-color: transparent;
}
&.lazy-loader {
padding: 20px 0 40px;
}
}

View File

@ -1,6 +1,6 @@
.search-form {
z-index: 2;
z-index: 3;
position: relative;
input {
@ -18,6 +18,18 @@
&.sidebar {
padding: 10px 15px 0 15px;
.autocomplete-field {
.results {
top: 42px;
}
&.loading {
.input:after {
background: $turquoise;
}
}
}
}
}

View File

@ -80,16 +80,30 @@ input[type="submit"] {
}
&.secondary {
color: #000000;
border-color: #000000;
color: $black;
border-color: $black;
&:hover {
color: lighten( #000000, 40% );
border-color: lighten( #000000, 40% );
color: lighten( $black, 40% );
border-color: lighten( $black, 40% );
}
&:active {
color: #FFFFFF;
background: #000000;
border-color: #000000;
color: $white;
background: $black;
border-color: $black;
}
}
&.alternative {
color: $blue;
border-color: $blue;
&:hover {
color: darken($blue, 10%);
border-color: darken($blue, 10%);
}
&:active {
color: $white;
background: $blue;
border-color: $blue;
}
}

View File

@ -159,5 +159,9 @@ $bp_shallow: 650px;
@media( min-width: $min_width ){
@content;
}
}@else if $min_height {
@media( min-height: $min_height ){
@content;
}
}
}

View File

@ -1,7 +1,101 @@
.discover-view {
section {
padding: 40px;
.intro {
position: relative;
padding: 60px 40px;
z-index: 1;
.parallax {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.liner {
z-index: 2;
position: relative;
color: $white;
h1 {
padding-bottom: 8px;
}
h3 {
padding-bottom: 40px;
opacity: 0.5;
}
}
}
section.grid-wrapper {
padding-bottom: 10px;
&:not(:first-child){
padding-top: 0;
}
}
.seeds {
.seed {
display: inline-block;
background: $blue;
color: $white;
padding: 8px 26px 8px 14px;
font-size: 16px;
position: relative;
vertical-align: top;
margin: 0 10px 10px 0;
.type {
display: inline-block;
font-size: 12px;
opacity: 0.5;
padding: 0 10px 0 5px;
}
.remove {
font-size: 10px;
padding: 12px;
position: absolute;
top: 0;
right: 0;
cursor: pointer;
}
}
}
.autocomplete-field {
width: 250px;
display: inline-block;
vertical-align: top;
padding: 0;
.input {
float: none;
margin: 0 10px 10px 0;
input {
background: rgba(100,100,100,0.5);
color: $white;
padding: 7px 13px;
font-size: 16px;
border: 1px solid transparent;
&:focus,
&:active {
border-color: $blue;
}
}
&:after {
background: $blue;
}
}
}
@include responsive( $bp_medium ){