Fork me on GitHub

CSS, LESS and Bootstrap

Note: If you skipped ahead to this section, click here to download a zip of the tutorial at this point.

In this chapter we will add Twitter's bootstrap to our project, and add some style and layout to the project.

At the moment, this tutorial we will use only Bootstrap's CSS file and not the JavaScript - but note that you can use all the features of Boostrap 4.

Adding and importing Bootstrap 4

First, we need to add Boostrap 4 to our project - so let's do that.

Run the following command in your Terminal:

$ meteor npm install [email protected] --save
17.4 Import bootstrap client/main.js
1
2
3
4
5
import angular from 'angular';
import 'bootstrap/dist/css/bootstrap.css';
 
import { Meteor } from 'meteor/meteor';
 

And it will import Boostrap's CSS to your project.

Add LESS

OK, simple styling works, but we want to be able to use LESS.

We can't add LESS from NPM because it is a compiler and we want it to be a part of Meteor build - so we will add it from Atmosphere:

$ meteor add less

We will use LESS in a few steps!

First touch of style

Now let's add some style! We will set just a background color.

17.3 Add main.less client/main.less
1
2
3
body {
  background-color: #f9f9f9;
}

Let's move loginButton to Navigation and set .container-fluid to the uiView directive.

1
2
3
<navigation class="navbar navbar-static-top navbar-dark bg-inverse"></navigation>
 
<div ui-view="" class="container-fluid"></div>

Converting to Bootstrap doesn't stop here. By applying bootstrap styles to various other parts of our Socially app, our website will look better on different screens. Have a look at Code Diff to see how we changed the structure of the main files.

Now we can create .less file for Socially:

1
2
3
socially {
  display: block;
}

And apply it to main less file:

17.5 Import future Socially less file client/main.less
1
2
3
4
5
body {
  background-color: #f9f9f9;
}
 
@import "../imports/ui/components/socially/socially.less";

To make bootstrap working with all sizes of screens:

17.8 Refactor index.html client/index.html
1
2
3
4
5
6
7
8
9
10
11
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <base href="/">
  <title>Socially</title>
  <script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyDbphq9crcdpecbseKX3Yx2LPxMRqWK-rc"></script>
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css">
</head>
<body>
  <socially></socially>

Navigation

Move loginButtons under Navigation and set as a bootstrap's navbar:

1
2
3
4
5
6
<div class="fluid-container">
  <div class="navbar-header">
    <a href="/parties" class="navbar-brand">Socially</a>
    <login-buttons class="navbar-brand"></login-buttons>
  </div>
</div>
1
2
3
navigation {
  display: block;
}

PartiesList

We will use bootstrap's grid system and make all warnings look a lot better:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<div class="container-fluid">
  <div class="row">
    <div class="col-md-12">
      <party-add ng-show="partiesList.isLoggedIn"></party-add>
 
      <div class="alert alert-warning" role="alert" ng-hide="partiesList.isLoggedIn">
        <strong>Warning!</strong>
        Log in to create a party!
      </div>
    </div>
  </div>
  <div class="row">
    <div class="col-md-12">
      <h2>List of parties:</h2>
      <form class="form-inline">
        <div class="form-group">
          <input type="search" ng-model="partiesList.searchText" placeholder="Search" class="form-control"/>
        </div>
        <parties-sort class="form-group" on-change="partiesList.sortChanged(sort)" property="name" order="1"></parties-sort>
      </form>
    </div>
  </div>
  <div class="row">
    <div class="col-md-6">
      <ul class="parties">
        <li dir-paginate="party in partiesList.parties | itemsPerPage: partiesList.perPage" total-items="partiesList.partiesCount">
          <div class="row">
            <div class="col-sm-8">
              <h3 class="party-name">
                <a ui-sref="partyDetails({ partyId: party._id })">{{party.name}}</a>
              </h3>
              <p class="party-description">
                {{party.description}}
              </p>
            </div>
            <div class="col-sm-4">
              <party-remove party="party" ng-show="partiesList.isOwner(party)"></party-remove>
            </div>
          </div>
          <div class="row">
            <div class="col-md-12">
              <party-rsvp party="party" ng-show="partiesList.isLoggedIn"></party-rsvp>
              <div class="alert alert-warning" role="alert" ng-hide="partiesList.isLoggedIn">
                <strong>Warning!</strong>
                <i>Sign in to RSVP for this party.</i>
              </div>
            </div>
          </div>
          <div class="row">
            <div class="col-md-4">
              <party-creator party="party"></party-creator>
            </div>
            <div class="col-md-8">
              <party-rsvps-list rsvps="party.rsvps"></party-rsvps-list>
            </div>
          </div>
        </li>
      </ul>
 
      <dir-pagination-controls on-page-change="partiesList.pageChanged(newPageNumber)"></dir-pagination-controls>
    </div>
    <div class="col-md-6">
      <parties-map parties="partiesList.parties"></parties-map>
    </div>
  </div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
parties-list {
  display: block;
  padding: 25px 0;
 
  ul.parties {
    padding-left: 0;
    list-style-type: none;
 
    > li {
      padding: 15px;
      background-color: #fff;
      margin: 15px 0;
      border: 3px solid #eaeaea;
 
     .party-name {
       margin-top: 0px;
       margin-bottom: 20px;
       a {
         text-decoration: none !important;
         font-weight: 400;
       }
     }
     .party-description {
       font-weight: 300;
       padding-left: 18px;
       font-size: 14px;
     }
    }
  }
}

We will no longer be using PartyUnanswered, time to remove it:

14
15
16
17
18
19
 
81
82
83
84
85
86
87
import { name as PartyCreator } from '../partyCreator/partyCreator';
import { name as PartyRsvp } from '../partyRsvp/partyRsvp';
import { name as PartyRsvpsList } from '../partyRsvpsList/partyRsvpsList';
 
class PartiesList {
  constructor($scope, $reactive) {
...some lines skipped...
  PartyRemove,
  PartyCreator,
  PartyRsvp,
  PartyRsvpsList
]).component(name, {
  template,
  controllerAs: name,

PartyAdd

Let's use .form-group and .form-control classes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<form>
  <div class="form-group">
    <label>
      Party Name:
    </label>
    <input type="text" ng-model="partyAdd.party.name" class="form-control"/>
  </div>
  <div class="form-group">
    <label>
      Description:
    </label>
    <input type="text" ng-model="partyAdd.party.description" class="form-control"/>
  </div>
  <div class="checkbox">
    <label>
      <input type="checkbox" ng-model="partyAdd.party.public"/> Public Party?
    </label>
  </div>
  <button ng-click="partyAdd.submit()" class="btn btn-success">Add Party!</button>
</form>
1
2
3
4
5
6
7
8
9
10
party-add {
  display: block;
}
 
party-add > form {
  padding: 15px;
  margin-bottom: 25px;
  background-color: #fff;
  border: 3px solid #EAEAEC;
}
28
29
30
31
32
    }
  }
}
 
@import "../partyAdd/partyAdd.less";

PartiesMap

1
2
3
4
5
6
<h4>
  See all the parties:
</h4>
<div class="angular-google-map-container">
  <ui-gmap-google-map center="partiesMap.map.center" zoom="partiesMap.map.zoom">
    <ui-gmap-markers models="partiesMap.parties" coords="'location'" fit="true" idkey="'_id'" doRebuildAll="true"></ui-gmap-markers>
1
2
3
4
5
6
7
8
9
parties-map {
  display: block;
  margin: 15px 5px;
 
  .angular-google-map-container {
    width: 100%;
    height: 400px;
  }
}
30
31
32
33
}
 
@import "../partyAdd/partyAdd.less";
@import "../partiesMap/partiesMap.less";

PartiesSort

1
2
3
4
<select ng-model="partiesSort.order" ng-change="partiesSort.changed()" class="form-control">
  <option value="1">Ascending</option>
  <option value="-1">Descending</option>
</select>

PartyCreator

Let's add a icon:

1
2
3
4
5
<p>
  <small>
    <i class="fa fa-user fa-lg"></i> {{ partyCreator.creator | displayNameFilter }}
  </small>
</p>
1
2
3
4
5
6
party-creator {
  i.fa {
    margin-left: 5px;
    margin-right: 10px;
  }
}
31
32
33
34
 
@import "../partyAdd/partyAdd.less";
@import "../partiesMap/partiesMap.less";
@import "../partyCreator/partyCreator.less";

PartyRemove

We will use icon of X provided by bootstrap v4:

1
<button type="button" class="close" aria-label="Close" ng-click="partyRemove.remove()"><span aria-hidden="true">&times;</span></button>

PartyRsvp

Let's make RSVP a lot prettier! User will be able to see how he responded:

1
2
3
<input type="button" value="I'm going!" ng-click="partyRsvp.yes()" class="btn btn-default" ng-class="{'btn-primary' : partyRsvp.isYes()}"/>
<input type="button" value="Maybe" ng-click="partyRsvp.maybe()" class="btn btn-default" ng-class="{'btn-primary' : partyRsvp.isMaybe()}"/>
<input type="button" value="No" ng-click="partyRsvp.no()" class="btn btn-default" ng-class="{'btn-primary' : partyRsvp.isNo()}"/>
1
2
3
4
party-rsvp {
  display: block;
  margin: 15px 0;
}
32
33
34
35
@import "../partyAdd/partyAdd.less";
@import "../partiesMap/partiesMap.less";
@import "../partyCreator/partyCreator.less";
@import "../partyRsvp/partyRsvp.less";

And create few methods to check the answer:

1
2
3
4
5
6
 
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
 
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import angular from 'angular';
import angularMeteor from 'angular-meteor';
import _ from 'underscore';
 
import { Meteor } from 'meteor/meteor';
 
...some lines skipped...
  yes() {
    this.answer('yes');
  }
  isYes() {
    return this.isAnswer('yes');
  }
 
  maybe() {
    this.answer('maybe');
  }
  isMaybe() {
    return this.isAnswer('maybe');
  }
 
  no() {
    this.answer('no');
  }
  isNo() {
    return this.isAnswer('no');
  }
 
  answer(answer) {
    Meteor.call('rsvp', this.party._id, answer, (error) => {
...some lines skipped...
      }
    });
  }
  isAnswer(answer) {
    if(this.party) {
      return !!_.findWhere(this.party.rsvps, {
        user: Meteor.userId(),
        rsvp: answer
      });
    }
  }
}
 
const name = 'partyRsvp';

PartyRsvpsList

We will no longer use PartyRsvpUsers, so we can remove it:

2
3
4
5
6
7
 
9
10
11
12
13
14
15
import angularMeteor from 'angular-meteor';
 
import template from './partyRsvpsList.html';
 
class PartyRsvpsList { }
 
...some lines skipped...
 
// create a module
export default angular.module(name, [
  angularMeteor
]).component(name, {
  template,
  controllerAs: name,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<div class="rsvp-sum">
  <div class="rsvp-amount">
    <div class="amount">
      {{ (partyRsvpsList.rsvps | filter:{rsvp:'yes'}).length || 0 }}
    </div>
    <div class="rsvp-title">
      YES
    </div>
  </div>
  <div class="rsvp-amount">
    <div class="amount">
      {{ (partyRsvpsList.rsvps | filter:{rsvp:'maybe'}).length || 0 }}
    </div>
    <div class="rsvp-title">
      MAYBE
    </div>
  </div>
  <div class="rsvp-amount">
    <div class="amount">
      {{ (partyRsvpsList.rsvps | filter:{rsvp:'no'}).length || 0 }}
    </div>
    <div class="rsvp-title">
      NO
    </div>
  </div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
.rsvp-sum {
  width: 160px;
  @media (min-width: 400px) {
    float: right;
  }
  @media (max-width: 400px) {
    margin: 0 auto;
  }
  > .rsvp-amount {
    display: inline-block;
    text-align: center;
    width: 50px;
 
    > .amount {
      font-weight: bold;
      font-size: 20px;
    }
    > .rsvp-title {
      font-size: 11px;
      color: #aaa;
      text-transform: uppercase;
    }
  }
}
33
34
35
36
@import "../partiesMap/partiesMap.less";
@import "../partyCreator/partyCreator.less";
@import "../partyRsvp/partyRsvp.less";
@import "../partyRsvpsList/partyRsvpsList.less";

PartyUninvited

1
2
3
4
5
6
7
8
9
10
<h4>Users to invite:</h4>
<ul>
  <li ng-repeat="user in partyUninvited.users | uninvitedFilter:partyUninvited.party">
    <button ng-click="partyUninvited.invite(user)" class="btn btn-primary-outline">Invite</button>
    {{ user | displayNameFilter }}
  </li>
</ul>
<div class="alert alert-success" role="alert" ng-if="(partyUninvited.users | uninvitedFilter:partyUninvited.party).length <= 0">
  Everyone are already invited.
</div>
1
2
3
4
5
6
7
8
party-uninvited {
  display: block;
 
  ul {
    padding-left: 0;
    list-style-type: none;
  }
}

PartyDetails

Let's do pretty much the same as we did with PartyAdd and PartyDetails:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<div class="container-fluid">
  <div class="row">
    <div class="col-md-6">
      <form>
        <fieldset class="form-group">
          <label>Party name</label>
          <input type="text" ng-model="partyDetails.party.name" ng-disabled="!partyDetails.isOwner" class="form-control"/>
        </fieldset>
 
        <fieldset class="form-group">
          <label>Description</label>
          <input type="text" ng-model="partyDetails.party.description" ng-disabled="!partyDetails.isOwner" class="form-control"/>
        </fieldset>
 
        <div class="checkbox">
          <label>
            <input type="checkbox" ng-model="partyDetails.party.public" ng-disabled="!partyDetails.isOwner"/>
            Public Party?
          </label>
        </div>
 
        <button ng-click="partyDetails.save()" type="submit" class="btn btn-primary">Save</button>
      </form>
    </div>
    <div class="col-md-6">
      <party-map location="partyDetails.party.location"></party-map>
    </div>
  </div>
 
  <div class="row">
    <div class="col-md-6">
      <party-uninvited party="partyDetails.party" ng-show="partyDetails.canInvite()"></party-uninvited>
    </div>
  </div>
</div>
1
2
3
4
5
6
7
8
9
party-details {
  display: block;
 
  form {
    margin: 25px 0;
  }
}
 
@import "../partyUninvited/partyUninvited.less";

PartyMap

We will remove partyMap.css and replace it with partyMap.less:

1
2
3
4
5
6
7
8
9
10
party-map {
  display: block;
  width: 100%;
  margin: 25px 0;
 
  .angular-google-map-container {
    width: 100%;
    height: 400px;
  }
}
3
4
5
6
7
8
import 'angular-simple-logger';
import 'angular-google-maps';
 
import template from './partyMap.html';
 
class PartyMap {
7
8
9
10
}
 
@import "../partyUninvited/partyUninvited.less";
@import "../partyMap/partyMap.less";

Update Socially

Now we can import all less files of direct Socially dependencies:

1
2
3
4
5
6
7
8
socially {
  display: block;
}
 
 
@import "../navigation/navigation.less";
@import "../partiesList/partiesList.less";
@import "../partyDetails/partyDetails.less";

That's it! Now we have a nice style with a better looking CSS using Bootstrap and LESS!

Summary

We learned how to use CSS, LESS and Bootstrap in Meteor.