info343/lib/courseweb/js/scripts.js

// (function() {
   var CONFIG;
   var SECTION = 'a';
   var NOW = new Date();
   var WEEK = 1;
   
   var DEPENDENCIES = {
      home: ['home.php', 'updates.xml', 'calendar.xml', 'lectures.xml', 'labs.xml'],
      calendar: ['calendar.php', 'updates.xml', 'calendar.xml', 'lectures.xml', 'labs.xml'],
      lectures: ['calendar.xml', 'lectures.xml', 'labs.xml', 'video.html'],
      labs: ['calendar.xml', 'lectures.xml', 'labs.xml'],
      homework: ['homeworks.xml', 'assignments.xml'],
      dropbox: ['dropbox.php', 'calendar.xml', 'assignments.xml', 'lectures.xml', 'labs.xml', 'homeworks.xml', 'tags.txt', 'dropbox_feedback.php'],
      syllabus: ['syllabus.php'],
      software: ['software.php'],
      staff: ['staff.php'],
      forums: [],
      submitted: []
   };
   
   var DATA = {};
   var PAGES = {};
   var SLIDERS = {
      primary: ['home', 'calendar', 'lectures', 'labs', 'homework', 'syllabus', 'software', 'staff'],
      dropbox: ['dropbox', 'submitted']
   };
   
   var PAGE;
   var PATH;
   
   $.ajaxSetup({
      'cache': false,
      'ifModified': true
   });
   
   
   // After the DOM and config file have finished loading, load dependencies for the specified
   // internal page and then launch the skeleton framework.
   $.when(
      $.Deferred(function() { $(this.resolve); }).then(function() { console.log('DOM loaded'); }),
      $.get(BASENAME + '/course.json').then(function(data) {
         CONFIG = data;
         setpath();
         console.log('config loaded');
      }).then(createSectionSelector).then(function() {
         return load(PAGE);
      })
   ).then(launch);
   
   
   // Functions to dynamically generate page content using the appropriate XML data.
   var initialize = {
      
      // Lectures: Get and inject a list of all lectures, including minilabs, video play buttons,
      // and lecture example files.
      'lectures': function() {
         var $page = page('lectures').append(header('Lectures'));
         $(DATA['lectures.xml']).find('lectures > lecture').each(function(i, lecture) {
            var $lecture = $(lecture);
            $page.append(
               
               // Generate a standard article for this lecture, plus minilab, video button,
               // and lecture example files.
               generate('lecture', $('<article>'), $lecture, {
                  modifiers: [
                     
                     // Add minilab.
                     function(data, $calendar_lecture, $container) {
                        return $container.append(generate('minilab', $('<aside>'), $lecture.children('minilab')));
                     },
                     
                     // Add video play button if recording available.
                     function(data, $calendar_lecture, $container) {
                        var $video = $('<a>');
                        if ($calendar_lecture.attr('recorded') == 'true') {
                           $video.attr('href', "javascript:playlecture('" + data.id + "','" + data.date + "','" + SECTION + "')");
                           $video.addClass('videolink');
                           $video.append($('<span>').addClass('play').text('▶'));
                           $video.append($('<span>').addClass('textonly').text('Play lecture'));
                           $video = $('<div>').append($video);
                        }
                        if ($video.children().length) {
                           $container.append($video);
                        }
                        return $container;
                     },
                     
                     // Add lecture example files, if any.
                     function(data, $calendar_lecture, $container) {
                        var $files = $('<dl>').addClass('fileset');
                        $calendar_lecture.children('fileset').each(function(i, fileset) {
                           var $fileset = $(fileset);
                           var label = $fileset.attr('label');
                           $files.append($('<dt>').text(label));
                           var $ul = $('<ul>');
                           $fileset.children('file').each(function(j, file) {
                              var $file = $(file);
                              var name = $file.attr('name');
                              var label = $file.attr('label');
                        
                              var $a = $('<a>');
                              if (/^(\w+:)?\/\/?/.test(name)) { // protocol:// absolute url
                                 $a.attr('href', name);
                              } else {
                                 $a.attr('hhref', BASENAME + '/lectures/' + data.id + '/files/' + SECTION + '/' + name);
                              }
                              $a.text(name.split('/').pop());
                        
                              var $li = $('<li>').append($a);
                              if (label) {
                                 $li.append(' ' + label);
                              }
                              if ($file.attr('supporting') == 'true') {
                                 $li.addClass('supporing');
                              }
                              $ul.append($li);
                           });
                           $files.append($('<dd>').append($ul));
                        });
                        if ($files.children().length) {
                           $container.append($files);
                        }
                        return $container;
                     }
                  ]
               })
            );
         });
      },
      // Labs: Get and inject a list of labs, skipping all labs past the first marked "tentative",
      // if any.
      'labs': function() {
         var $labs = $(DATA['labs.xml']).find('labs lab');
         var $page = page('labs').append(header('Labs'));
         if ($labs.length) {
            $labs.each(function(i, lab) {
               var $article = generate('lab', $('<article>'), $(lab));
               $page.append($article);
               
               // If this lab is tentative, return false to "break" out of this $.each() iteration,
               // so that labs are displayed only up until the first tentative lab.
               if ($article.hasClass('tentative')) {
                  return false;
               }
            });
         } else {
            $page.append(noneyet('labs'));
         }
      },
      // Homework: Get and inject a list of all assignments whose open dates have passed (and
      // should thus be shown), or a "none yet" message if none.
      'homework': function() {
         var shown = [];
         var $homeworks = $(DATA['homeworks.xml']).find('homeworks homework');
         $.each($homeworks.get().reverse(), function(i, homework) {
            shown.push(generate('homework', $('<article>'), $(homework)));
         });
         
         var $page = page('homework').append(header('Homework'));
         if (shown.length) {
            $page.append(shown);
         } else {
            $page.append(noneyet('homework assignments'));
         }
      },
      
      // Dropbox: 
      'dropbox': function() {
         $('#submitting').hide();
      },
      
      // Home: Scavenge most recent updates and this week's calendar row from the calendar page,
      // ensuring that page gets loaded first.
      'home': function() {
         load('calendar').then(function() {
            create('calendar');
            
            // Copy this week's calendar row.
            page('home').find('table.calendar').empty().append(page('calendar').find('table.calendar tr:nth-child(' + WEEK + ')').clone());
            
            // Extract first 6 updates, then prune if they're too long.
            var $home_updates = page('home').find('.updates dl').empty();
            var charcount = 0;
            page('calendar').find('.updates dl > *').slice(0, 6).each(function(i, elem) {
               if (elem.nodeName == 'DT') {
                  $home_updates.append($(elem).clone());
               } else {
                  var text = elem.textContent.replace(/\s+/g, ' ');
                  charcount += text.length;
                  if (charcount > 500) {
                     // Undo addition of the dt.
                     $home_updates.children().last().remove();
                  } else {
                     $home_updates.append($(elem).clone());
                  }
               }
            });
         });
      },
      
      // Calendar: Generate calendar table (with lectures, labs, and minlabs) and list of
      // recent updates.
      'calendar': function() {
         // Generate calendar table.
         var $table = page('calendar').find('table.calendar').empty();
         var $calendar = $(DATA['calendar.xml']);
         $calendar.find('week').each(function(i, week) {
            var $tr = $('<tr>');
            $(week).find('section[id=' + SECTION + '] > *').each(function(j, calendar_entry) {
               var $calendar_entry = $(calendar_entry);
               // console.log($calendar_entry[0]);
               var $td = $('<td>').addClass(['lecture-a', 'lab', 'lecture-b'][j]);
               var date = $calendar_entry.attr('date');
               var id = $calendar_entry.attr('id');
               if ($calendar_entry.attr('blank') != 'true') {
                  if (id) {
                     var type = calendar_entry.nodeName;
                     var $type_data = $(DATA[type + 's.xml']);
                     var $type_entry = $type_data.find(type + '[id="' + id + '"]');
                     
                     $td = generate(type, $td, $type_entry, { '$calendar_entry': $calendar_entry });
                     
                     if (type == 'lecture') {
                        var $minilab = $type_entry.children('minilab');
                        if ($minilab.length) {
                           $td.append(generate('minilab', $('<aside>'), $minilab));
                        } else {
                           $td.addClass('noaside');
                        }
                     }
                  } else { // !id - generate from title/description in calendar, not lecture/lab
                     $td = generate('calendar', $td, $calendar_entry, { '$calendar_entry': $calendar_entry });
                     $td.addClass('noaside');
                  }
               } else {
                  // console.log('(blank calendar entry; no type entry)');
               }
               $td.html('<div class="wrapper">' + $td.html() + '</div>');
               $table.append($tr.append($td));
               // console.log($td[0]);
            });
         });
         
         // Populate Recent Updates section.
         var $dl = page('calendar').find('.updates dl').empty();
         $(DATA['updates.xml']).find('item').each(function(i, item) {
            var $item = $(item);
            var date  = new Date($item.children('pubDate').text());
            var month = zeropad(date.getMonth() + 1);
            var day   = zeropad(date.getDate());
            
            var $dt   = $('<dt>').text(month + '.' + day);
            
            var title = $item.children('title').text();
            var description = $item.children('description').text();
            var link  = $item.children('link').text();
            
            if (link) {
               title = '<a href="' + link + '">' + title + '</a>';
            }
            
            var content;
            if (title.replace(/<\/?[^>]+>/g, '').replace(/[^\w]/g, '') != description.replace(/<\/?[^>]+>/g, '').replace(/[^\w]/g, '')) {
               content = '<p>' + title + '</p>' + 
                         '<aside>' + description + '</aside>';
            } else {
               content = title;
            }
            
            var $dd = $('<dd>').html(content);
            
            $dl.append($dt).append($dd);
         });
         
         // Set height of calendar wrapper; will be overridden by .single when applied.
         page('calendar').find('calendar.wrapper').css({
            'height': $table.outerHeight()
         });
         
         // Pushstate-ify "show all" link.
         page('calendar').find('header nav.show a.showall').click(function(event) {
            event.preventDefault();
            event.stopPropagation();
            navigate('calendar', this.href);
         });
         
         // // TODO: pushstate-ify all links
         // $('.page.calendar a').each(function(i, elem) {
         //    pushstateify(elem);
         // });
      }
   }
   
   // Functions to configure a page for viewing based on the given path. Called immediately
   // before the page is navigated to using that path.
   var show = {
      
      // Calendar: 
      'calendar': function(path) {
         console.log(path);
         var showall = page('calendar').find('header nav.show a.showall')[0];
         switch (path[1]) {
            case 'full':
               showall.innerHTML = 'show only this week';
               showall.href = BASENAME + '/calendar/';
               // show full calendar
               page('calendar').find('nav.week').hide();
               page('calendar').find('.calendar.wrapper').removeClass('single');
               page('calendar').find('table.calendar').css('transform', 'none');
               break;
            case 'week':
               showall.innerHTML = 'show entire quarter';
               showall.href = BASENAME + '/calendar/full/';
               showweek('calendar', parseInt(path[2]));
               break;
            default:
               showall.innerHTML = 'show entire quarter';
               showall.href = BASENAME + '/calendar/full/';
               showweek('calendar', WEEK);
               break;
         }
      }
   }
   
   // Extract the id, title, and description from the given $entry, and use them to generate
   // and return a "standard article" for the data type. Most types return a header with h3 & h4,
   // followed by a paragraph description, with some variations.
   function generate(type, $container, $entry, options) {
      var id = $entry.attr('id');
      var title = $entry.children('title').text();
      var description = $entry.children('description').text();
      
      if (type == 'minilab') {
         if (id && title) {
            var content = '<strong>' + title + ':</strong>';
            if (description) content += '<span class="space"> </span>' + description;
            if (id) {
               var num = id.replace('minilab-', '');
               $container.append($('<a>').attr('href', BASENAME + '/minilabs/' + num + '/').html(content));
            } else {
               $container.append(content);
            }
         }
      } else {
         var subtitle, after = '', tentative = false;
         var $date_entry;
         if (type == 'homework') {
            var $assignments = $(DATA['assignments.xml']);
            var $turnin = $assignments.find('homework[id="' + id + '"] turnin');
            
            var opens = new Date($turnin.attr('opens'));
            var closes = new Date($turnin.attr('closes'));
            var shown = (opens <= NOW);
            
            if (!shown) {
               console.log('skipping assignment ', $entry[0], ' with open date ', opens, ' > ', NOW);
               return $();
            }
            
            var open = (NOW <= closes);
            var closemonth = zeropad(closes.getMonth() + 1);
            var closeday = zeropad(closes.getDate());
            
            subtitle = closemonth + '.' + closeday;
            after = '<p class="duedate">Due: <time>' + datestring(closes) + '</time></p>'
            
            $date_entry = $turnin;
         } else {
            var $calendar_entry;
            if (options && options.$calendar_entry) {
               $calendar_entry = options.$calendar_entry;
            } else {
               $calendar_entry = $(DATA['calendar.xml']).find('section[id="' + SECTION + '"] ' + type + '[id="' + id + '"]');
            }
            var date = $calendar_entry.attr('date');
            
            tentative = $calendar_entry.attr('tentative') == 'true';
            subtitle = date.substring(5, 7) + '.' + date.substring(8);
            $date_entry = $calendar_entry;
         }
         
         var content = '<header>' +
                          (type == 'calendar' ? '<h3>' : '<h3 id="' + id + '">') + title + '</h3>' +
                          '<h4>' + subtitle + '</h4>' +
                       '</header>' +
                       '<p>' + description + '</p>' + after;
         
         if (tentative) {
            $container.addClass('tentative');
            $container.html(content);
         } else {
            var num = type == 'lecture' ? id : id.replace(/(homework|lab)-/, '');
            $container.html('<a href="' + BASENAME + '/' + type + (type != 'homework' ? 's' : '') + '/' + num + '/">' + content + '</a>');
         }
         
         if (options && options.modifiers) {
            $.each(options.modifiers, function(i, mod) {
               $container = mod({
                  id: id, title: title, description: description
               }, $date_entry, $container);
            });
         }
      }
      return $container;
   }
   
   function header(pagename) {
      return $('<header>').append($('<h2>').text(pagename));
   }
   
   // Load the specified page's dependencies (using cache unless 'force' is truthy),
   // and populate and initialize the page.
   function load(pagename, force) {
      console.log((force ? 'FORCE ' : '') + "loading dependencies for page '" + pagename + "'");
      
      var $loading = $.Deferred();
      var remaining = DEPENDENCIES[pagename].length;
      var modified = [];
      if (remaining) {
         
         // Counting semaphore funtion, called after each dependency is loaded (or failed).
         // If any dependencies were updated, marks their dependent pages as dirty.
         // TODO: array of Promises instead?
         function finish() {
            if (--remaining == 0) {
               // if any dependencies were updated, mark other depdendent pages as dirty
               $.each(modified, function(i, file) {
                  $.each(DEPENDENCIES, function(pg, deplist) {
                     if (deplist.indexOf(file) != -1) {
                        console.log(pg + ' is now dirty');
                        if (PAGES[pg]) PAGES[pg] = 'dirty';
                     }
                  });
               });
               $loading.resolve();
            }
         }
         
         // load all dependencies for this page
         $.each(DEPENDENCIES[pagename], function(i, file) {
            var parts = file.split('.');
            var pagename = parts[0];
            var ext = parts[1];
            var path = '';
            
            if (ext == 'txt') {
               dir = '.';
               path = file;
            } else if (ext == 'html' || ext == 'php') {
               if (['home', 'calendar', 'syllabus', 'software', 'staff', 'dropbox'].indexOf(pagename) != -1) {
                  dir = '';
                  path = (pagename == 'home' ? '' : '/' + pagename) + '/?fragment';
               } else {
                  dir = 'inc';
                  path = file;
               }
            } else {
               dir = ext;
               path = file;
            }
            
            if (ext != 'html' && ext != 'php' || ['home', 'calendar', 'syllabus', 'software', 'staff'].indexOf(pagename) == -1) {
               dir = '/' + dir;
               path = '/' + path;
            }
            
            var requestfile = BASENAME + dir + path;
            
            if (DATA[file] && !force) {
               console.log('dependency ' + requestfile + ' already loaded');
               finish();
            } else {
               console.log('loading dependency ' + requestfile);
               $.get(requestfile).done(function(data, msg, jqXHR) {
                  if (jqXHR.status == 304) {
                     console.log('dependency ' + file + ' unchanged');
                  } else {
                     DATA[file] = data;
                     modified.push(file);
                     console.log('dependency ' + file + ' loaded');
                  }
               }).always(finish);
            }
         });
      } else {
         $loading.resolve();
      }
      return $loading;
   }
   
   // Return a string representation of the given date, in the format: Sunday, January 1, 2013, 1:00 PM
   function datestring(date) {
      var weekday = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][date.getDay()];
      var month = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][date.getMonth()];
      var day = date.getDate();
      var year = date.getFullYear();
      var hours = date.getHours();
      var minutes = zeropad(date.getMinutes());
      var am_pm = (hours >= 0 && hours < 12) ? 'AM' : 'PM';
      hours %= 12;
      return weekday + ', ' + month + ' ' + day + ', ' + year + ', ' + hours + ':' + minutes + ' ' + am_pm;
   }
   
   // Pad the given num with a 0, if necessary, to make it 2 digits wide.
   function zeropad(num) {
      return (num < 10 ? '0' : '') + num;
   }
   
   // Return a paragraph with the message "No <things> posted yet." Parameter 'things' should be plural.
   function noneyet(things) {
      return $('<p>').text('No ' + things + ' posted yet.');
   }
   
   // Create and inject the section selector.
   function createSectionSelector() {
      // var sections = ['a'];
      // $('#course_id').append($('<ul>').attr('id', 'section'));
      // $.each(sections, function(i, section) {
      //    var $a = $('<a>').text(section.toUpperCase());
      //    // $a.click(sectionChange);
      //    var $li = $('<li>').append($a);
      //    // if (SECTION == section) {
      //       $li.addClass('selected');
      //    // }
      //    $('#section').append($li);
      // });
      // var offset = sections.indexOf(SECTION);
      // $('#section').children().first().css('margin-top', (-offset * .85) + 'em');
      $('#course_id').append($('<ul>').attr('id', 'section').append($('<li>').text('a')));
   }
   
   // Display the specified week in the single-height calendar on the given page ('home' or 'calendar').
   function showweek(pagename, week) {
      page('calendar').find('.calendar.wrapper').addClass('single').children('table.calendar').css({
         '-webkit-transform': 'translate(0, -' + (week-1) * 8.5 + 'em)',
         'transform': 'translate(0, -' + (week-1) * 8.5 + 'em)'
      });
      
      if (pagename == 'calendar') {
         page(pagename).find('nav.week span.number').text('Week ' + week);
         arrows(week);
      }
   }
   
   // Relink the up/down navigation arrows on the calendar page, centered on the specified week.
   function arrows(week) {
      page('calendar').find('nav.week').show();
      page('calendar').find('header nav.week a.week').each(function(i, link) {
         var chg = i == 0 ? -1 : 1;
         var newweek = week + chg;
         if (newweek > 0 && newweek < page('calendar').find('table.calendar tr').length + 1) {
            link.href = BASENAME + '/calendar/week/' + newweek;
            $(link).unbind('click').hover(
               function(event) {
                  $(link).children('canvas.hover').show();
               },
               function(event) {
                  $(link).children('canvas.hover').hide();
               }
            ).click(function(event) {
               event.preventDefault();
               event.stopPropagation();
               history.replaceState(null, null, link.href);
               showweek('calendar', newweek);
            }).children('canvas.disabled').hide();
         } else {
            link.href = 'javascript:';
            $(link).unbind('click').unbind('hover').children('canvas.disabled').show();
         }
      });
   }
   
   // Parse and return the portions of the URL relevant to internal navigation.
   function splitpath(path) {
      console.log("splitpath: ", path);
      path = path.replace(BASENAME + '/', '');
      var parts = path.split('#');
      path = parts[0];
      var hash = '#' + parts[1];
      path = path.replace(/\/$/, ''); // remove trailing slash to avoid empty entry
      path = path.split('/');
      path.push(hash);
      console.log("Path: ", path);
      return path;
   }
   
   // Set the PAGE and PATH based on the current URL.
   function setpath() {
      PATH = splitpath(location.pathname + location.hash);
      PAGE = PATH[0] || 'home';
      console.log('PATH:', PATH, 'PAGE:', PAGE);
   }
   
   // Navigate to the specified internal page, optionally pushState-ing the given URL. Calculate
   // the page height necessary and handle animation if transitioning between internal pages.
   function navigate(newpage, href) {
      console.log("navigating to page '" + newpage + "' with url '" + href + "'");
      
      var oldpage = PAGE;
      var transition = oldpage != newpage;
      
      if (transition) $(document.body).removeClass(oldpage);
      $(document.body).addClass(newpage);
      
      if (show[newpage]) {
         var path = splitpath(href ? href.replace(/^\w+:\/\/[^\/]+/, '') : location.pathname + location.hash);
         show[newpage](path);
      }
      
      // Update URL if new one provided.
      if (href) {
         history.pushState(null, null, href);
         setpath();
      }
      
      if (!transition) {
         // Not coming from a previous page (e.g., this is initial pageload). Just
         // position everything properly.
         
         var height = pageheight(newpage);
         var slider = SLIDERS.dropbox.indexOf(newpage) != -1 ? 'dropbox' : 'primary';
         var index  = slider == 'primary' ? SLIDERS.primary.indexOf(newpage) : 0;
         
         console.log(height, slider, index);
         
         // Translate slider so that appropriate page is in view (unanimated)
         if (slider == 'primary') {
            $('.slider.primary').css({
               'height': height,
               'transform': 'translate(-' + (index * 55) + 'em, 0)'
            });
            $('.slider.dropbox').css('visibility', 'hidden');
         } else if (slider == 'dropbox') {
            $('.slider.dropbox').css({
               'height': height,
               'transform': 'translate(0, -10em)'
            });
            $('.slider.primary').css('visibility', 'hidden');
         }
         $('#main').height(height);
         
         // Position masthead at x-position according to page's index in slider, and y-position
         // according to which slider it's on.
         $('header.masthead').css('transform', 'translate(-' + (index * .25) + 'em, ' + (slider == 'dropbox' ? '-.35em' : '0') + ')');
      } else {
         // Navigating to new internal page. Prefetch, stage, and finally transition
         // to new slider / page.
         
         var newheight = pageheight(newpage);
         var oldheight = pageheight(oldpage == newpage ? null : oldpage);
         
         var oldslider = SLIDERS.dropbox.indexOf(oldpage) != -1 ? 'dropbox' : 'primary';
         var newslider = SLIDERS.dropbox.indexOf(newpage) != -1 ? 'dropbox' : 'primary';
         
         if (newslider == 'dropbox') {
            var newindex = SLIDERS.dropbox.indexOf(newpage);
            
            if (oldslider == 'primary') {
               // Going from primary → dropbox.
               
               var displacement = (parseFloat(oldheight) + parseFloat($('.slider.dropbox').css('margin-top'))) + 'px';
               console.log(displacement, oldheight);
               
               // Stage dropbox slider: reposition slider to new page's index WITHOUT animating.
               animate('off', ['.slider.dropbox', '.slider.primary']);
               $('.slider.dropbox').css({
                  'height': newheight,
                  'transform': 'translate(-' + (newindex * 55) + 'em, 0)'
               });
               $('.slider.primary').css({
                  'height': oldheight,
                  'transform': 'translate(-' + (oldindex * 55) + 'em, -' + displacement + ')'
               });
               
               // Transition to new slider / page (animated).
               setTimeout(function() {
                  // Turn on animation.
                  animate('on', ['.slider.dropbox', '.slider.primary', 'header.masthead']);
                  
                  // Show dropbox slider.
                  $('.slider.dropbox').css('visibility', 'visible');
                  
                  // Move both sliders up.
                  var oldindex = SLIDERS.primary.indexOf(oldpage);
                  $('.slider.dropbox').css('transform', 'translate(-' + (newindex * 55) + 'em, -' + displacement + ')');
                  $('.slider.primary').css('transform', 'translate(-' + (oldindex * 55) + 'em, -' + displacement + ')');
                  
                  // Hide primary slider when it's finished sliding up.
                  setTimeout(function() {
                     $('.slider.primary').css('visibility', 'hidden');
                  }, 500); // FIXME: make constant
                  
                  // Slide masthead up at same x-position.
                  $('header.masthead').css('transform', 'translate(-' + (oldindex * .25) + 'em, -.35em)');
               }, 1);
            } else {
               // Going from dropbox → dropbox.
               // Slide dropbox slider to new page's index.
               $('.slider.dropbox').css({
                  'height': newheight,
                  'transform': 'translate(-' + (newindex * 55) + 'em, -' + newheight + ')'
               });
               
               // Slide masthead to new x-position.
               $('header.masthead').css('transform', 'translate(-' + (newindex * .25) + 'em, -.35em)');
            }
         } else if (newslider == 'primary') {
            var newindex = SLIDERS.primary.indexOf(newpage);
            
            if (oldslider == 'dropbox') {
               // Going from dropbox → primary.
               
               var displacement = (parseFloat(newheight) + parseFloat($('.slider.dropbox').css('margin-top'))) + 'px';
               var oldindex = SLIDERS.dropbox.indexOf(oldpage);
               
               // Stage primary slider: reposition slider to new page's index WITHOUT animating.
               animate('off', ['.slider.primary', '.slider.dropbox']);
               $('.slider.primary').css({
                  'height': newheight,
                  'transform': 'translate(-' + (newindex * 55) + 'em, -' + displacement + ')'
               });
               $('.slider.dropbox').css('transform', 'translate(-' + (oldindex * 55) + 'em, -' + displacement + ')');
               
               // Transition to new slider / page (animated).
               setTimeout(function() {
                  // Turn on animation.
                  animate('on', ['.slider.primary', '.slider.dropbox', 'header.masthead']);
                  
                  // Show primary slider.
                  $('.slider.primary').css('visibility', 'visible');
                  
                  // Move both sliders down.
                  $('.slider.primary').css('transform', 'translate(-' + (newindex * 55) + 'em, 0)');
                  $('.slider.dropbox').css('transform', 'translate(-' + (oldindex * 55) + 'em, 0)');
                  
                  // Hide dropbox slider when it's finished sliding down.
                  setTimeout(function() {
                     $('.slider.dropbox').css('visibility', 'hidden');
                  }, 500); // FIXME: make constant
                  
                  // Slide masthead down and to new x-position.
                  $('header.masthead').css('transform', 'translate(-' + (newindex * .25) + 'em, 0)');
               }, 1);
            } else {
               // Going from primary → primary.
               
               // Slide dropbox slider to new page's index.
               $('.slider.primary').css({
                  'height': newheight,
                  'transform': 'translate(-' + (newindex * 55) + 'em, 0)'
               });
               
               // Slide masthead to new x-position.
               $('header.masthead').css('transform', 'translate(-' + (newindex * .25) + 'em, 0)');
            }
         }
         $('#main').height(newheight);
      }
   }
   
   // Calculate and return the specified page's occupied height (the height #main should be set
   // to in order to exactly encompass the page's contents).
   function pageheight(pagename) {
      if (!pagename) return '0px';
      
       var $page = page(pagename);
      var height = 0;
      
      // if (pagename == 'home') {
         // height = '45em';
      // } else if (pagename == 'calendar') {
      if (pagename == 'calendar') {
         page('calendar').children().each(function(i, elem) {
            var $elem = $(elem);
            var childheight = 0;
            
            if (i == 1) {
               var $cal = $elem.children('table.calendar');
               if ($elem.hasClass('single')) {
                  childheight = $cal.find('tr:first-child').outerHeight()
                               // + parseFloat($cal.css('margin-top')) + parseFloat($cal.css('margin-bottom'))
                               + parseFloat($cal.css('padding-top')) + parseFloat($cal.css('padding-bottom'));
                               // + parseFloat($cal.css('border-top')) + parseFloat($cal.css('border-bottom'))
               } else {
                  childheight = $cal.outerHeight();
               }
               childheight += parseFloat($elem.css('margin-top')) + parseFloat($elem.css('margin-bottom'));
            } else {
               childheight = $elem.outerHeight(true);
            }
            
            height += childheight;
         });
         
         height += parseFloat(page('calendar').css('margin-top'));
         height += 'px';
      } else if (pagename == 'dropbox') {
         height = $page.outerHeight() + parseFloat($page.css('margin-top')) + 'px';
      } else {
         height = $page.outerHeight(true) + 'px';
      }
      
      return height;
   }
   
   // Enable animated transitions on the masthead, #main, #content, body, and calendar.
   function animate(state, elements) {
      var easeOutSine = 'cubic-bezier(0.39, 0.575, 0.565, 1)';
      
      var timing = '.5s';
      
      var transitions = {
         'header.masthead': 'transform ' + timing + ' linear',
         '#main': 'height ' + timing + ' ' + easeOutSine,
         '.slider.primary': 'transform ' + timing + ' ' + easeOutSine,
         '.slider.dropbox': 'transform ' + timing + ' ' + easeOutSine,
         'body': 'transform ' + timing + ' ' + easeOutSine + ', opacity ' + timing + ' ' + easeOutSine,
         '.page.calendar .calendar.wrapper': 'height .25s ' + easeOutSine,
         '.page.calendar table.calendar': 'transform .2s ' + easeOutSine
      }
      
      state = state || 'on';
      if (!elements) elements = Object.keys(transitions);
      else if (!$.isArray(elements)) elements = [ elements ];
      
      console.log('turning animation ' + state + ' for:', elements);
      
      if (state == 'on') {
         $.each(elements, function(i, elem) {
            $(elem).css({
               'transition': transitions[elem],
               '-webkit-transition': transitions[elem].replace('transform', '-webkit-transform')
            });
         });
      } else {
         $.each(elements, function(i, elem) {
            $(elem).css({
               'transition': 'none',
               '-webkit-transition': 'none'
            })
         });
      }
   }
   
   // Fetch and return the page of the given name.
   function page(name) {
      return $('.page.' + name);
   }
   
   // (Re-)create the specified page.
   function create(pagename) {
      if (DATA[pagename + '.php']) {
         page(pagename).html(DATA[pagename + '.php']);
      }
      
      if (initialize[pagename]) initialize[pagename]();
      
      PAGES[pagename] = 'initialized';
   }
   
   // Set up the page skeleton, attaching pushState-ified click and prefetching mouseover handlers
   // to navigation links, showing the current PAGE, and enabling animated transitions.
   function launch() {
      console.log("page load complete");
      
      var i = 0;
      $.each(DEPENDENCIES, function(pagename, deps) {
         // Create page and add to appropriate slider.
         var $pg = $('<div>').addClass('page ' + pagename);
         if (pagename == 'dropbox') {
            $pg.appendTo('.slider.dropbox');
         } else {
            $pg.appendTo('.slider.primary');
            $pg.css('left', (i * 55) + 'em');
            // $pg.data('nav-index', i);
            i++;
         }
      });
      $('<div>').addClass('page submitted').css({
         'left': '55em'
      }).appendTo('.slider.dropbox');
      
      // Handle forums click differently.
      $('#nav_forums a').click(function(event) {
         event.preventDefault();
         event.stopPropagation();
         
         $(document.body).css({
            'transform': 'translate(-200%)',
            'opacity': 0
         });
         
         window.location.href = event.target.href;
      });
      
      var $loading = deferred().resolve();
      
      // PushState-ify main navigation links (excl. dropbox and forums), and make mouse hovers
      // prefetch content.
      $('.branding a, .masthead nav ul li:not(#nav_dropbox):not(#nav_forums) a').each(function(i, elem) {
         $(elem).click(navclick).mouseover(primaryover);
      });
      
      $('#nav_dropbox a').click(navclick).mouseover(dropboxover);
      
      // Go to the page specified in the URL, and make everything visible.
      create(PAGE);
      navigate(PAGE);
      $(document.body).css('visibility', 'visible');
      
      // Enable animated transitions.
      setTimeout(animate, 10);
      
      
      // ===== LOCAL FUNCTIONS =====
      
      // Create and return a new jQuery Deferred object, stamped with a unique id for concurrency
      // testing.
      function deferred() {
         var $def = $.Deferred();
         Object.defineProperty($def, '__id', {
            value: new Date().getTime()
         });
         return $def;
      }
      
      // Navigate to the clicked page.
      function navclick(event) {
         event.preventDefault();
         event.stopPropagation();
         
         var pagename = event.delegateTarget.parentNode.id ? event.delegateTarget.parentNode.id.replace('nav_', '') : 'home';
         var url = event.delegateTarget.href;
         
         console.log(event.delegateTarget, pagename, url, $loading.state());
         
         $loading.then(function() {
            navigate(pagename, url);
         });
      }
      
      // Preload the target page of the hovered-over navigation link, as well as all intervening
      // pages between that and the current page in the horizontal page "slider".
      function primaryover(event) {
         // if ($loading.state() == 'pending') {
         //    $loading.resolve();
         // }
         
         $loading = $.Deferred();
         // var loadid = $loading.__id;
         
         var pagename = event.delegateTarget.parentNode.id ? event.delegateTarget.parentNode.id.replace('nav_', '') : 'home';
         
         var to_load = [];
         var dest = SLIDERS.primary.indexOf(pagename);
         
         if (PAGE == 'dropbox' || PAGE == 'submitted') {
            to_load.push(SLIDERS.primary.indexOf(pagename));
            console.log('on dropbox, going to load', to_load, pagename);
         } else {
            var cur = SLIDERS.primary.indexOf(PAGE);
            
            var m = Math.min(dest, cur);
            var n = Math.max(dest, cur);
            
            for (var i = m; i <= n; i++) {
               var pg = SLIDERS.primary[i];
               if (!PAGES[pg] || i == dest) to_load.push(i);
            }
            console.log(dest, m, n, to_load);
         }
         
         if (to_load.length) {
            $.each(to_load, function(i, index) {
               var pg = SLIDERS.primary[index];
               console.log('load:', pg);
               // if (!PAGES[pg]) {
                  load(pg, index == dest).then(function() {
                     if (!PAGES[pg] || (index == dest && PAGES[pg] == 'dirty')) {
                        create(pg);
                     }
                     // to_load.pop();
                     // console.log('remaining to load: ', to_load);
                     if (i == to_load.length - 1) {
                        // if ($loading.__id == loadid) {
                           $loading.resolve();
                           // console.log('resolving fresh loader');
                        // } else {
                           // console.log('stale loader; won\'t resolve');
                        // }
                     }
                  });
               // } else {
               //    console.log('load: PAGES[pg] exists:', PAGES[pg]);
               // }
            });
         } else {
            $loading.resolve();
         }
      }
      
      // Prelaod the dropbox page.
      function dropboxover(event) {
         $loading = $.Deferred();
         
         var pagename = 'dropbox';
         // if (!PAGES[pagename]) {
            // console.log('dropbox: before load');
            load(pagename, true).then(function() {
               console.log('dropbox: load done');
               if (!PAGES[pagename] || PAGES[pagename] == 'dirty') {
                  console.log('dropbox: going to create');
                  create(pagename);
                  console.log('dropbox: created');
               }
               console.log('dropbox: going to resolve');
               $loading.resolve();
            });
         // }
      }
   }
// })();