Click here to Skip to main content
15,867,568 members
Articles / Web Development / ASP.NET

YouGrade - Asp.NET MVC Multimedia Exam Suite

Rate me:
Please Sign up or sign in to vote.
4.93/5 (99 votes)
8 Jun 2011CPOL15 min read 231K   6.3K   192   105
A multimedia exam suite built on Asp.NET and Youtube

Image 1

Table of Contents

Introduction

Last year I presented a Silvelight application which I called YouGrade. At that time it had a good reception in the Code Project community, in fact better than I expected it to have, and I thought it would be a good programming exercise to rewrite it for the Asp.NET MVC platform. Althought one could argue about "which one is the best", the fact is that Silverlight and Asp.NET are two first-class citizens on the web ecosystem and deserve all my respect. Another reason for me to post this article about Asp.NET is that I haven't found anything similar (so far, at least), so I hope the Asp.NET community enjoys it.

Besides exploring the potential use of this kind of application, this article also describes the use of various programming techniques, such as the MVC pattern programming, the jQuery and jQuery-UI javascript frameworks for the client-side development, AJAX techiniques, the basics of YouTube API programming, Entity Framework for object-relational mapping and querying, AutoMapper for mapping between entities and plain data transfer object classes.

System Requirements

To use YouGrade application provided with this article, if you already have Visual Studio 2010 with Asp.NET MVC 3.0, that's fine. If you don't, you can download the following 100% free development tool directly from Microsoft:

The Concept Behind YouGrade

The goal of this article is to provide a muiti-purpose suite for multimedia online exams. It is "multimedia" because it allows you to use YouTube videos as resource material for the questions. In short, the person who applies to the test can watch/listen to the YouTube video, read the question and answer it accordingly. This article shows how to take advantage of some neat qualities of YouTube: you can create your own videos for your own tests, and upload them for free to YouTube and have them working for your tests. Or you can use already existent YouTube videos in your exams. It's up to you to decide, based upon your needs. As readers will see later on the article, YouTube exposes an API for JavaScript that turns YouTube into a fully-programmable tool.

The Model View Controller Pattern

In my opinion, MVC is the best thing that happened to Asp.NET development in the last years. I find it beautiful and elegant pattern. I never liked the Asp.NET WebForms that much. And even so, I'm happy that Asp.NET MVC is by no means a replacement for WebForms: instead, it's just another way of doing things. And its adherence to the principle of separation of concerns indeed forces the experienced Asp.NET WebForms developer to think things in a different ways, but it doesn't mean things must be harder for the developer.

Image 2

This means that there are no more codebehind classes on the view side to perform business logic. No more viewstate and no more postbacks to handle. As soon as you install the Asp.NET MVC project template with Visual Studio, you notice that there are some folders created especifically for the MVC development: There's the Models folder, then the Views Folder and then the Controllers folder. This is the convention for MVC projects, and it means "it's a good idea to put your views on the Views folder, models on the Models folder and controllers on the Controllers folder". Of course, you can move your controllers to another folder, or even to another assembly (this last option is very usual). But these are conventions, so if you're not planning on splitting your code among several assemblies, so it's a good idea to stick to the conventions. This approach is called "convention over configuration" and applies to many modern frameworks (such as MonoRail, Asp.NET MVC and Ruby on Rails) and frees the developer from the necessity of setting up several configuration files. That is, Asp.NET MVC is a flexible framework, and yet you only need to configure it for your needs when your needs are unconventional for the Asp.NET MVC framework. For example: if you want a new controller, just create a controller class and make it inherit from the Controller class, and you're good to go. But if you want to keep your controller classes on different assemblies then that you must configure by yourself, so that the MVC framework be able to locate the controllers.

The Model

The data model for the YouGrade application is persisted in a local database using the object-relational mapper provided by Entity Framework. The following diagram shows the entities and their relationships:

Image 3

The entities in the model were create to represent the minimum data structure required for running the exams:

  • The User represents the person that takes the test.
  • The ExamDef represents the Exam Definition, there is, a exam template to which many users can apply.
  • The QuestionDef is the entity that holds each question in the exam.
  • The Alternative describes each of the valid alternatives and one or more correct alternatives.
  • The ExamTake represents each attempt of the user in applying to an exam.
  • The Answer represents the user's answer to each alternative in each question.

The View

The View part of the MVC is indeed very simple: there's only one view, which is responsible for displaying the exam title, the current question number, the current question text, the current associated video, the given alternatives. Also, it provides the user with interface controls, such as the question navigation buttons, and the checkboxes/radiobuttons for the question valid alternatives.

Image 4

The Controller

The HomeController is the counterpart of the Home Views, and provides all functionalities needed for the interaction between the client-side and server-side operations.

In order to interact with the View, the HomeController must expose a set of actions. These actions are called by the browser or by the View itself (via AJAX calls) for navigating through the questions or saving the user's answers:

  • The Index action is called right away when the application is started, and tells the Asp.NET MVC framework to render the Index with data from the exam.
  • The GetQuestion action is called by the Index view (via AJAX call) so that the question, the video and the alternatives can be rendered on the screen.
  • The SaveAnswer is a POST action that receives the question ID and the user's answers for that question. These answers are then saved in an in-memory object that holds temporarily the user's answers before being persisted to the database.
  • The MoveToPreviousQuestion action is called by the Index view (via AJAX call) to navigate to the previous question, and it also returns that question data back to the view.
  • The MoveToNextQuestion action is called by the Index view (via AJAX call) to navigate to the next question, and it also returns that question data back to the view.
  • The EndExam action is called by the Index view (via AJAX call) to calculate the user results (from a correct percentage range varying from 0 to 100 points).

Using jQuery for events, AJAX calls and bindings

I dare to say that, after the introduction of jQuery, programming JavaScript turned to be a real joy. I used jQuery whenever possible, and it gave me a compact, yet readable, set of instructions:

This is how we handle the window load event with jQuery:

JavaScript
$(window).load(function () {
...

In order to deal with the hover events on view buttons, we handle the hover jQuery event like this:

JavaScript
$('.button').hover(
    function () {
        $(this).removeClass('ui-state-default');
        $(this).addClass('ui-state-hover');
    },
    function () {
        $(this).addClass('ui-state-default');
        $(this).removeClass('ui-state-hover');
    });

The AJAX calls are done in a clear and simple manner: here we provide the url to the GetQuestion action on the HomeController, the type (GET), the return type (json - javascript simple object notation) and the event handlers for both the error and success events. In case of success, the question is retrieved by the JSON object and rendered by the view.

JavaScript
	$.ajax({
		url: '/Home/GetQuestion',
		type: 'GET',
		cache: false,
		dataType: 'json',
		error: function (jqXHR, textStatus, errorThrown) {
			alert(errorThrown);
		},
		success: function (json) {
			question = json;
			renderQuestion(json);
		}
	});
...

And here is how I manage to iterate through the alternatives. Notice the smart $.each($('.alternatives > input') jQuery syntax, that allows iteration over each input element inside the element of the alternative class in a clean and readable way:

JavaScript
	$.each($('.alternatives > input'), function (key, value) {
		if ($(this).attr('value') == 'on') {
			answers = answers + String.fromCharCode(65 + key);
		}
	});
...

The following code snippet shows how to render html code inside divs pertaining to the "questionTitle" and "questionText" classes:

JavaScript
	$('.questionTitle').html('Question ' + q.Id);
	$('.questionText').html(q.Text);
...

Using YouTube API for JavaScript

I think the YouTube API is the icing in the cake of this application.

Today, there are plenty of websites and blogs that make use of embedded YouTube videos. This application is not different.

But something is really different here: instead of just embedding, we also use a set of instructions that control the embedded video. This is possible thanks to the YouTube API.

Some simple steps are necessary to make this happen. First, we create a div element to embed our video:

HTML
<div id="videoDiv" style="z-index: -1;">
    You need Flash player 8+ and JavaScript enabled to view this video.
</div>

Then we use the "swfobject.embedSWF" method passing some parameters, such as the name of the div which holds the video, the video size, and the window mode:

JavaScript
var question;
// The video to load.
var videoID = "iapcKVn7DdY"
http: //www.youtube.com/watch?v=
// Lets Flash from another domain call JavaScript
var params = { allowScriptAccess: "always", wmode: "transparent" };
// The element id of the Flash embed
var atts = { id: "ytPlayer" };
// All of the magic handled by SWFObject
//(http://code.google.com/p/swfobject/)
swfobject.embedSWF("http://www.youtube.com/v/" +
videoID + "&enablejsapi=1&playerapiid=ytPlayer&wmode=opaque",
           "videoDiv", "480", "295", "8", null, null, params, atts);
var ytplayer;

Image 5

Below is the full syntax:

swfobject.embedSWF(swfUrlStr, replaceElemIdStr, widthStr, heightStr, swfVersionStr, xiSwfUrlStr, flashvarsObj, parObj, attObj)

  • swfUrlStr - This is the URL of the SWF. Note that we have appended the enablejsapi and playerapiid parameters to the normal YouTube SWF URL to enable JavaScript API calls.
  • replaceElemIdStr - This is the HTML DIV id to replace with the embed content. In the example above, it is ytapiplayer.
  • widthStr - Width of the player.
  • heightStr - Height of the player.
  • swfVersionStr - The minimum required version for the user to see the content. In this case, version 8 or above is needed. If the user does not have 8 or above, they will see the default line of text in the HTML DIV.
  • xiSwfUrlStr - (Optional) Specifies the URL of your express install SWF. Not used in this example.
  • flashVarsObj - (Optional) Specifies your FlashVars in name:value pairs. Not used in this example.
  • parObj - (Optional) The parameters for the embed object. In this case, we've set allowScriptAccess.
  • AttObj - (Optional) The attributes for the embed object. In this case, we've set the id to myytplayer.

Once the video is embedded and the player is ready, the YouTube API will call the onYouTubePlayerReady function and give you control of the video. So, you must have this code if you want to make use of the API:

JavaScript
function onYouTubePlayerReady(playerId) {
    ytplayer = document.getElementById(playerId);
    getQuestion();
}

The following code was extracted from the renderQuestion function, and shows 3 functions from YouTube API: stopVideo, loadVideoById and playVideo. Notice that the ytplayer is the object we instantiated in the above function. The loadVideo function receives a video id as the first parameter and the position (in seconds) in which the video must start. This is particularly useful when you have a long video and you want the user to start watching it at some specific point:

JavaScript
function renderQuestion(q) {
    question = q;

    ytplayer.stopVideo();
    ytplayer.loadVideoById(q.Url, q.StartSeconds);
    ytplayer.playVideo();
    ...

YouTube Time Links

YouTube video links are cool, and I think it would be a nice enhancement for this project. The idea is to find any mm:ss matches inside the question text and replace these time markers with time links so that the user can "jump" to that specific time in the video.

First, we must create the question text with the time markers in our database. We should be able to create as many time markers as we want. It's up to the application to deal with them all. Notice that we want to keep the question text in our database clean and readable. So, we don't put HTML tags in it:

Image 6

Second, we handle the question text on the server side, so that the timer markers be replaced with the proper html tags. The time link tag should look like:

HTML
<a href="#" onclick="ytplayer.seekTo( 60 * mm * 0 + ss);return false;">mm:ss</a>

Where:

  • ytplayer is our player object name.
  • seekTo is the built-in YouTube API method. This method makes the player jump to the specified second. Notice that we use 60 * mm * 0 + ss as the formula for calculating the total seconds.
  • mm:ss is the time marker we are trying to replace by the html link.

In order to convert the ordinary text into HTML links, we add a few lines of code to our GetQuestion on the server side, using the Regular Expression "(\d|\d\d):(\d{2})" (that finds the mm:ss pattern) to replace the question text accordingly:

C#
public QuestionDefDto GetQuestion()
{
	...
	... some code here
	...
	//Here we create time links for the video, wherever the question text
	//matches the time regular expression
	var regexTime = new Regex(@"(\d|\d\d):(\d{2})");
	string newQuestionText = regexTime.Replace(questionDefDto.Text, 
		new MatchEvaluator(
			(target) => 
				{ 
					var timeSplit = target.ToString().Split(':');
					return string.Format("<a href="\"#\"" önclick="\"ytplayer.seekTo(60*{0}+{1});return">{0}:{1}</a>", 
						timeSplit[0], timeSplit[1]);
				}
			));

	questionDefDto.Text = newQuestionText;

	return questionDefDto;
}

If we didn't do any mistake, the above code should be enough. Now we run the application and find out if the links are working:

Image 7

Okay, now that both links are working, we inspect our browser elements and see the html code that has been generated by our regular expression replacement:

Image 8

Multi-Select Questions

There are instances when the question requires more than one answer. In such cases, you can use multi-select questions:

Image 9

Multi-select question is a question where the IsMultiSelect attribute is set to true.

Unlike single-select questions, where the alternatives are radio buttons, the alternatives for multi-select questions are rendered as check boxes on the browser side by this javascript code:

JavaScript
for (var i = 0; i < q.Alternatives.length; i++) {
    var checked = q.Alternatives[i].IsChecked
        '? 'checked="true"' : '';
    var type = q.IsMultiSelect ? 'checkbox' : 'radio';
    $('.alternatives').append('<input id="alt' + q.Alternatives[i].Id +
    '" name="alternatives" type="' + type + '" ' + checked + ' />' +
    q.Alternatives[i].Id + '. ' + q.Alternatives[i].Text + '<br />');
}

Showing Exam Results

When the user finishes the exam, he/she must end it in order to see the results.

Along with the results, the user also receives the minimum results for the exam, so that both can be compared. This is done by a pair of progress bars, provided by the jQuery-ui, a jQuery plugin.

Image 10

Once again, jQuery is our friend, and we're lucky it comes with a beautiful syntax for ajax commands.

JavaScript
function endExam() {
    ytplayer.pauseVideo();
    saveAnswer(function () {
        $('#endExamDialog').dialog('open');
        $.ajax({
            url: '/Home/EndExam',
            type: 'GET',
            cache: false,
            dataType: 'json',
            data: ({}),
            error: function (jqXHR, textStatus, errorThrown) {
                alert(errorThrown);
            },
            success: function (json) {
                $("#progressbarYourResults").progressbar({
                    value: json.result
                });
                $('#yourResult').html(json.result);

                $("#progressbarMinimum").progressbar({
                    value: json.minimum
                });
                $('#minimum').html(json.minimum);
            }
        });
    });
}

Notice that the above code shows that the ajax command is called only after the saveAnswer is terminated. This is so because the saveAnswer itself also makes another ajax calls. Since ajax are asynchronous invocations to the server, we must for the results of saveAnswer call before requesting the results. Otherwise we might get inconsistent results.

The progress bars are created by the results provided by the EndExam action:

C#
[HttpGet]
public ActionResult EndExam()
{
    var result = ExamManager.Instance.EndExam();
    var examDefDto = ExamManager.Instance.GetExam();

    return Json(
        new {
            success = true,
            result = result,
            minimum = (int)((100 * examDefDto.MinimumOfCorrectAnswers) / examDefDto.Questions.Count())
        },
        JsonRequestBehavior.AllowGet);
}

Using Entity Framework

The ADO.NET Entity Framework plays a big role in our application. This object-relation mapping (ORM) framework abstracts the relational data residing in our YouGrade.mdf local database and presents the conceptual schema to the application.

By generating the conceptual schema from the local database, we now have a set of entities that map to the corresponding tables. Any change made to the database schema can be updated in the conceptual schema through the help of a wizard. Likewise, changes to the conceptual entities can be also propagated to the underlying database tables. This allows a fast development and works great for our YouGrade application.

As you can see below, there are only 2 methods in the YouGradeService class that uses the Entity Framework entities. Simply put: the first one retrieves the exam data from the database. The other one saves the user's answers back to the database.

The GetExamDef method on YouGradeService class retrieves the entity containing the Exam Definition. In addition, all related questions and alternatives are also included in the result via the "Include" method. Without this metod, the return entity would contain only the data pertaining to the exam itself (such as Id, name and Description).

C#
public ExamDef GetExamDef()
{
    using (YouGradeEntities1 ctx = new YouGradeEntities1())
    {
        return ctx.ExamDef.Include("QuestionDef.Alternative").First();
    }
}

The SaveExamTake method receives an ExamTakeDto parameter and persist its data to the database. At first this method looks a bit complicated, but it simply saves the exam take data, and then the answers provided by the user.

C#
public double SaveExamTake(ExamTakeDto examTakeTO)
	{
		double grade = 0;
		try
		{
			using (YouGradeEntities1 ctx = new YouGradeEntities1())
			{
				var user = ctx.User.Where(e => (e.Id == examTakeTO.UserId)).First();
				ExamDef examDef = ctx.ExamDef.Where(e => e.Id == examTakeTO.ExamId).First();

				ExamTake newExamTake = ExamTake.CreateExamTake
					(
					0,
					examDef.Id,
					examTakeTO.UserId,
					examTakeTO.StartDateTime,
					examTakeTO.Duration,
					examTakeTO.Grade,
					examTakeTO.Status.ToString()
					);

				newExamTake.User = user;
				newExamTake.ExamDef = examDef;

				ctx.AddToExamTake(newExamTake);

				ctx.SaveChanges();

				foreach (AnswerDto a in examTakeTO.Answers)
				{
					ExamTake examTake = ctx.ExamTake
						.Where(e => e.Id == newExamTake.Id).First();
					Alternative alternative = ctx.Alternative.Where
					(e => e.QuestionId == 
						a.QuestionId).Where(e => e.Id == a.AlternativeId).First();
					Answer newAnswer = Answer
						.CreateAnswer(newExamTake.Id, a.QuestionId, a.AlternativeId, a.IsChecked);
					newAnswer.ExamTake = examTake;
					newAnswer.Alternative = alternative;
					ctx.AddToAnswer(newAnswer);
				}

				ctx.SaveChanges();

				foreach (QuestionDef q in ctx.QuestionDef)
				{
					var query = from qd in ctx.QuestionDef
						join a in ctx.Answer on qd.Id equals a.QuestionId
						join alt in ctx.Alternative on new 
						{ qId = a.QuestionId, aId = a.AlternativeId } 
						equals new { qId = alt.QuestionId, aId = alt.Id }
						where qd.Id == q.Id
						where a.ExamTakeId == newExamTake.Id
						select new { alt.Correct, a.IsChecked };

					bool correct = true;
					foreach (var v in query)
					{
						if (v.Correct != v.IsChecked)
						{
							correct = false;
							break;
						}
					}
					grade += correct ? 1 : 0;
				}

				int examTakeId = examTakeTO.Id;
			}

			using (YouGradeEntities1 ctx = new YouGradeEntities1())
			{
				ExamTake et = ctx.ExamTake.First();
				string s = et.Status;
			}

			return grade;
		}
		catch (Exception exc)
		{
			string s = exc.ToString();
			throw;
		}
	}

Image 11

Using AutoMapper

It would be very nice if we could serialize the entities generated by Entity Framework directly and use it directly in the view, as json objects.

Unfortunately, this is not so simple. If you try to serialize this question definition entity, you get an error indicating that there is a circular reference in the question definition object. Why this happens? In our case, the entities have "navigation properties", which means, for example, that for a given question definition, there are a property that points to a list of child alternatives. And for each alternative, there is also a navigation property that points back to the parent quetion. In order to solve this problem, I create a corresponding Data Transfer Object (DTO) class for each entity, so that I can "transfer" data from the entity objects to these POCO (Plain Old CLR Objects) and then serialize them to the view.

But how do we do this mapping? Creating new instances of DTO objects and transferring data to them can be a cumbersome task. Instead, we could use some automated mapping techinique. In this project I used AutoMapper, which proved to be friendly and powerful.

Here goes the description of AutoMapper:

AutoMapper uses a fluent configuration API to define an object-object mapping strategy. AutoMapper uses a convention-based matching algorithm to match up source to destination values. Currently, AutoMapper is geared towards model projection scenarios to flatten complex object models to DTOs and other simple objects, whose design is better suited for serialization, communication, messaging, or simply an anti-corruption layer between the domain and application layer.

Before we start mapping, we have to configure AutoMapper first. We do this by adding some code to the Global.asax.cs class:

C#
protected void Application_Start()
{
    ...
    //AutoMapper settings
    Mapper.CreateMap>ExamDef, ExamDefDto>()
        .ForMember(e => e.Questions, options => options.MapFrom(e => e.QuestionDef));

    Mapper.CreateMap<QuestionDef, QuestionDefDto>()
        .ForMember(e => e.Alternatives, options => options.MapFrom(e => e.Alternative));
    Mapper.CreateMap<Alternative, AlternativeDto>();
    Mapper.CreateMap<ExamTake, ExamTakeDto>();
    Mapper.CreateMap<Answer, AnswerDto>();
    ...
}

The above instructions tell the AutoMapper which entity classes map to each DTO classes. And the code below shows how to actually map from one object to another:

C#
var examDefDto = new ExamDefDto();
Mapper.Map(examDef, examDefDto);

return examDefDto;

Final Considerations

That's it! I hope you have enjoyed the article as much as I have. Please comment below, and any suggestions, complaints and ideas will be welcome.

History

  • 2011-05-30: Initial version.
  • 2011-06-01: YouTube Time links added.
  • 2011-06-05: Multi-select questions added.
  • 2011-06-06: Exam results added.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Instructor / Trainer Alura Cursos Online
Brazil Brazil

Comments and Discussions

 
QuestionYoutube videos question Pin
D Syfer6-Jan-21 2:54
D Syfer6-Jan-21 2:54 
GeneralMy vote of 5 Pin
Manoj Kumar Choubey9-Aug-12 20:12
professionalManoj Kumar Choubey9-Aug-12 20:12 
QuestionGreat! Good work guy.. Pin
neriacompany1-Jun-12 3:56
neriacompany1-Jun-12 3:56 
GeneralMy vote of 5 Pin
Ahsan Murshed20-May-12 18:51
Ahsan Murshed20-May-12 18:51 
QuestionExcellent. Thanks! Pin
kartalyildirim3-Mar-12 6:37
kartalyildirim3-Mar-12 6:37 
GeneralMy vote of 5 Pin
Pravin Patil, Mumbai20-Dec-11 0:56
Pravin Patil, Mumbai20-Dec-11 0:56 
GeneralRe: My vote of 5 Pin
Marcelo Ricardo de Oliveira1-Mar-12 15:46
mvaMarcelo Ricardo de Oliveira1-Mar-12 15:46 
Questionhow to i get to run this project... Pin
ashinshenoy19-Dec-11 7:14
ashinshenoy19-Dec-11 7:14 
AnswerRe: how to i get to run this project... Pin
Sacha Barber7-Feb-12 4:48
Sacha Barber7-Feb-12 4:48 
GeneralMy vote of 2 Pin
ashinshenoy19-Dec-11 7:09
ashinshenoy19-Dec-11 7:09 
GeneralRe: My vote of 2 Pin
Marcelo Ricardo de Oliveira1-Mar-12 15:51
mvaMarcelo Ricardo de Oliveira1-Mar-12 15:51 
QuestionHello Marcelo Pin
emiaj3-Jul-11 16:29
emiaj3-Jul-11 16:29 
AnswerRe: Hello Marcelo [modified] Pin
Marcelo Ricardo de Oliveira4-Jul-11 0:17
mvaMarcelo Ricardo de Oliveira4-Jul-11 0:17 
GeneralRe: Hello Marcelo Pin
emiaj4-Jul-11 5:13
emiaj4-Jul-11 5:13 
GeneralRe: Hello Marcelo Pin
Marcelo Ricardo de Oliveira4-Jul-11 6:30
mvaMarcelo Ricardo de Oliveira4-Jul-11 6:30 
GeneralRe: Hello Marcelo Pin
emiaj6-Jul-11 14:51
emiaj6-Jul-11 14:51 
GeneralRe: Hello Marcelo Pin
emiaj6-Jul-11 14:58
emiaj6-Jul-11 14:58 
GeneralRe: Hello Marcelo Pin
emiaj28-Nov-12 15:22
emiaj28-Nov-12 15:22 
Questiongood,but one question of using it Pin
liwenhaosuper24-Jun-11 16:09
liwenhaosuper24-Jun-11 16:09 
AnswerRe: good,but one question of using it Pin
Marcelo Ricardo de Oliveira30-Jun-11 12:27
mvaMarcelo Ricardo de Oliveira30-Jun-11 12:27 
GeneralRe: good,but one question of using it Pin
whq1013-Nov-11 19:41
whq1013-Nov-11 19:41 
GeneralRe: good,but one question of using it Pin
Jannacs718-Nov-11 22:42
Jannacs718-Nov-11 22:42 
GeneralMy vote of 5 Pin
thatraja23-Jun-11 20:47
professionalthatraja23-Jun-11 20:47 
GeneralRe: My vote of 5 Pin
Marcelo Ricardo de Oliveira24-Jun-11 3:02
mvaMarcelo Ricardo de Oliveira24-Jun-11 3:02 
GeneralMy vote of 5 Pin
Dr.Luiji20-Jun-11 21:11
professionalDr.Luiji20-Jun-11 21:11 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.