PageRenderTime 147ms CodeModel.GetById 24ms app.highlight 110ms RepoModel.GetById 0ms app.codeStats 1ms

/wp-admin/js/theme.js

https://gitlab.com/em645jn/brochure
JavaScript | 1903 lines | 1163 code | 397 blank | 343 comment | 143 complexity | ff91d4e4b9f6dbcd0c761c54010972b7 MD5 | raw file
   1/* global _wpThemeSettings, confirm */
   2window.wp = window.wp || {};
   3
   4( function($) {
   5
   6// Set up our namespace...
   7var themes, l10n;
   8themes = wp.themes = wp.themes || {};
   9
  10// Store the theme data and settings for organized and quick access
  11// themes.data.settings, themes.data.themes, themes.data.l10n
  12themes.data = _wpThemeSettings;
  13l10n = themes.data.l10n;
  14
  15// Shortcut for isInstall check
  16themes.isInstall = !! themes.data.settings.isInstall;
  17
  18// Setup app structure
  19_.extend( themes, { model: {}, view: {}, routes: {}, router: {}, template: wp.template });
  20
  21themes.Model = Backbone.Model.extend({
  22	// Adds attributes to the default data coming through the .org themes api
  23	// Map `id` to `slug` for shared code
  24	initialize: function() {
  25		var description;
  26
  27		// If theme is already installed, set an attribute.
  28		if ( _.indexOf( themes.data.installedThemes, this.get( 'slug' ) ) !== -1 ) {
  29			this.set({ installed: true });
  30		}
  31
  32		// Set the attributes
  33		this.set({
  34			// slug is for installation, id is for existing.
  35			id: this.get( 'slug' ) || this.get( 'id' )
  36		});
  37
  38		// Map `section.description` to `description`
  39		// as the API sometimes returns it differently
  40		if ( this.has( 'sections' ) ) {
  41			description = this.get( 'sections' ).description;
  42			this.set({ description: description });
  43		}
  44	}
  45});
  46
  47// Main view controller for themes.php
  48// Unifies and renders all available views
  49themes.view.Appearance = wp.Backbone.View.extend({
  50
  51	el: '#wpbody-content .wrap .theme-browser',
  52
  53	window: $( window ),
  54	// Pagination instance
  55	page: 0,
  56
  57	// Sets up a throttler for binding to 'scroll'
  58	initialize: function( options ) {
  59		// Scroller checks how far the scroll position is
  60		_.bindAll( this, 'scroller' );
  61
  62		this.SearchView = options.SearchView ? options.SearchView : themes.view.Search;
  63		// Bind to the scroll event and throttle
  64		// the results from this.scroller
  65		this.window.bind( 'scroll', _.throttle( this.scroller, 300 ) );
  66	},
  67
  68	// Main render control
  69	render: function() {
  70		// Setup the main theme view
  71		// with the current theme collection
  72		this.view = new themes.view.Themes({
  73			collection: this.collection,
  74			parent: this
  75		});
  76
  77		// Render search form.
  78		this.search();
  79
  80		// Render and append
  81		this.view.render();
  82		this.$el.empty().append( this.view.el ).addClass( 'rendered' );
  83	},
  84
  85	// Defines search element container
  86	searchContainer: $( '#wpbody h1:first' ),
  87
  88	// Search input and view
  89	// for current theme collection
  90	search: function() {
  91		var view,
  92			self = this;
  93
  94		// Don't render the search if there is only one theme
  95		if ( themes.data.themes.length === 1 ) {
  96			return;
  97		}
  98
  99		view = new this.SearchView({
 100			collection: self.collection,
 101			parent: this
 102		});
 103
 104		// Render and append after screen title
 105		view.render();
 106		this.searchContainer
 107			.append( $.parseHTML( '<label class="screen-reader-text" for="wp-filter-search-input">' + l10n.search + '</label>' ) )
 108			.append( view.el );
 109	},
 110
 111	// Checks when the user gets close to the bottom
 112	// of the mage and triggers a theme:scroll event
 113	scroller: function() {
 114		var self = this,
 115			bottom, threshold;
 116
 117		bottom = this.window.scrollTop() + self.window.height();
 118		threshold = self.$el.offset().top + self.$el.outerHeight( false ) - self.window.height();
 119		threshold = Math.round( threshold * 0.9 );
 120
 121		if ( bottom > threshold ) {
 122			this.trigger( 'theme:scroll' );
 123		}
 124	}
 125});
 126
 127// Set up the Collection for our theme data
 128// @has 'id' 'name' 'screenshot' 'author' 'authorURI' 'version' 'active' ...
 129themes.Collection = Backbone.Collection.extend({
 130
 131	model: themes.Model,
 132
 133	// Search terms
 134	terms: '',
 135
 136	// Controls searching on the current theme collection
 137	// and triggers an update event
 138	doSearch: function( value ) {
 139
 140		// Don't do anything if we've already done this search
 141		// Useful because the Search handler fires multiple times per keystroke
 142		if ( this.terms === value ) {
 143			return;
 144		}
 145
 146		// Updates terms with the value passed
 147		this.terms = value;
 148
 149		// If we have terms, run a search...
 150		if ( this.terms.length > 0 ) {
 151			this.search( this.terms );
 152		}
 153
 154		// If search is blank, show all themes
 155		// Useful for resetting the views when you clean the input
 156		if ( this.terms === '' ) {
 157			this.reset( themes.data.themes );
 158			$( 'body' ).removeClass( 'no-results' );
 159		}
 160
 161		// Trigger a 'themes:update' event
 162		this.trigger( 'themes:update' );
 163	},
 164
 165	// Performs a search within the collection
 166	// @uses RegExp
 167	search: function( term ) {
 168		var match, results, haystack, name, description, author;
 169
 170		// Start with a full collection
 171		this.reset( themes.data.themes, { silent: true } );
 172
 173		// Escape the term string for RegExp meta characters
 174		term = term.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' );
 175
 176		// Consider spaces as word delimiters and match the whole string
 177		// so matching terms can be combined
 178		term = term.replace( / /g, ')(?=.*' );
 179		match = new RegExp( '^(?=.*' + term + ').+', 'i' );
 180
 181		// Find results
 182		// _.filter and .test
 183		results = this.filter( function( data ) {
 184			name        = data.get( 'name' ).replace( /(<([^>]+)>)/ig, '' );
 185			description = data.get( 'description' ).replace( /(<([^>]+)>)/ig, '' );
 186			author      = data.get( 'author' ).replace( /(<([^>]+)>)/ig, '' );
 187
 188			haystack = _.union( [ name, data.get( 'id' ), description, author, data.get( 'tags' ) ] );
 189
 190			if ( match.test( data.get( 'author' ) ) && term.length > 2 ) {
 191				data.set( 'displayAuthor', true );
 192			}
 193
 194			return match.test( haystack );
 195		});
 196
 197		if ( results.length === 0 ) {
 198			this.trigger( 'query:empty' );
 199		} else {
 200			$( 'body' ).removeClass( 'no-results' );
 201		}
 202
 203		this.reset( results );
 204	},
 205
 206	// Paginates the collection with a helper method
 207	// that slices the collection
 208	paginate: function( instance ) {
 209		var collection = this;
 210		instance = instance || 0;
 211
 212		// Themes per instance are set at 20
 213		collection = _( collection.rest( 20 * instance ) );
 214		collection = _( collection.first( 20 ) );
 215
 216		return collection;
 217	},
 218
 219	count: false,
 220
 221	// Handles requests for more themes
 222	// and caches results
 223	//
 224	// When we are missing a cache object we fire an apiCall()
 225	// which triggers events of `query:success` or `query:fail`
 226	query: function( request ) {
 227		/**
 228		 * @static
 229		 * @type Array
 230		 */
 231		var queries = this.queries,
 232			self = this,
 233			query, isPaginated, count;
 234
 235		// Store current query request args
 236		// for later use with the event `theme:end`
 237		this.currentQuery.request = request;
 238
 239		// Search the query cache for matches.
 240		query = _.find( queries, function( query ) {
 241			return _.isEqual( query.request, request );
 242		});
 243
 244		// If the request matches the stored currentQuery.request
 245		// it means we have a paginated request.
 246		isPaginated = _.has( request, 'page' );
 247
 248		// Reset the internal api page counter for non paginated queries.
 249		if ( ! isPaginated ) {
 250			this.currentQuery.page = 1;
 251		}
 252
 253		// Otherwise, send a new API call and add it to the cache.
 254		if ( ! query && ! isPaginated ) {
 255			query = this.apiCall( request ).done( function( data ) {
 256
 257				// Update the collection with the queried data.
 258				if ( data.themes ) {
 259					self.reset( data.themes );
 260					count = data.info.results;
 261					// Store the results and the query request
 262					queries.push( { themes: data.themes, request: request, total: count } );
 263				}
 264
 265				// Trigger a collection refresh event
 266				// and a `query:success` event with a `count` argument.
 267				self.trigger( 'themes:update' );
 268				self.trigger( 'query:success', count );
 269
 270				if ( data.themes && data.themes.length === 0 ) {
 271					self.trigger( 'query:empty' );
 272				}
 273
 274			}).fail( function() {
 275				self.trigger( 'query:fail' );
 276			});
 277		} else {
 278			// If it's a paginated request we need to fetch more themes...
 279			if ( isPaginated ) {
 280				return this.apiCall( request, isPaginated ).done( function( data ) {
 281					// Add the new themes to the current collection
 282					// @todo update counter
 283					self.add( data.themes );
 284					self.trigger( 'query:success' );
 285
 286					// We are done loading themes for now.
 287					self.loadingThemes = false;
 288
 289				}).fail( function() {
 290					self.trigger( 'query:fail' );
 291				});
 292			}
 293
 294			if ( query.themes.length === 0 ) {
 295				self.trigger( 'query:empty' );
 296			} else {
 297				$( 'body' ).removeClass( 'no-results' );
 298			}
 299
 300			// Only trigger an update event since we already have the themes
 301			// on our cached object
 302			if ( _.isNumber( query.total ) ) {
 303				this.count = query.total;
 304			}
 305
 306			this.reset( query.themes );
 307			if ( ! query.total ) {
 308				this.count = this.length;
 309			}
 310
 311			this.trigger( 'themes:update' );
 312			this.trigger( 'query:success', this.count );
 313		}
 314	},
 315
 316	// Local cache array for API queries
 317	queries: [],
 318
 319	// Keep track of current query so we can handle pagination
 320	currentQuery: {
 321		page: 1,
 322		request: {}
 323	},
 324
 325	// Send request to api.wordpress.org/themes
 326	apiCall: function( request, paginated ) {
 327		return wp.ajax.send( 'query-themes', {
 328			data: {
 329			// Request data
 330				request: _.extend({
 331					per_page: 100,
 332					fields: {
 333						description: true,
 334						tested: true,
 335						requires: true,
 336						rating: true,
 337						downloaded: true,
 338						downloadLink: true,
 339						last_updated: true,
 340						homepage: true,
 341						num_ratings: true
 342					}
 343				}, request)
 344			},
 345
 346			beforeSend: function() {
 347				if ( ! paginated ) {
 348					// Spin it
 349					$( 'body' ).addClass( 'loading-content' ).removeClass( 'no-results' );
 350				}
 351			}
 352		});
 353	},
 354
 355	// Static status controller for when we are loading themes.
 356	loadingThemes: false
 357});
 358
 359// This is the view that controls each theme item
 360// that will be displayed on the screen
 361themes.view.Theme = wp.Backbone.View.extend({
 362
 363	// Wrap theme data on a div.theme element
 364	className: 'theme',
 365
 366	// Reflects which theme view we have
 367	// 'grid' (default) or 'detail'
 368	state: 'grid',
 369
 370	// The HTML template for each element to be rendered
 371	html: themes.template( 'theme' ),
 372
 373	events: {
 374		'click': themes.isInstall ? 'preview': 'expand',
 375		'keydown': themes.isInstall ? 'preview': 'expand',
 376		'touchend': themes.isInstall ? 'preview': 'expand',
 377		'keyup': 'addFocus',
 378		'touchmove': 'preventExpand',
 379		'click .theme-install': 'installTheme',
 380		'click .update-message': 'updateTheme'
 381	},
 382
 383	touchDrag: false,
 384
 385	initialize: function() {
 386		this.model.on( 'change', this.render, this );
 387	},
 388
 389	render: function() {
 390		var data = this.model.toJSON();
 391
 392		// Render themes using the html template
 393		this.$el.html( this.html( data ) ).attr({
 394			tabindex: 0,
 395			'aria-describedby' : data.id + '-action ' + data.id + '-name',
 396			'data-slug': data.id
 397		});
 398
 399		// Renders active theme styles
 400		this.activeTheme();
 401
 402		if ( this.model.get( 'displayAuthor' ) ) {
 403			this.$el.addClass( 'display-author' );
 404		}
 405	},
 406
 407	// Adds a class to the currently active theme
 408	// and to the overlay in detailed view mode
 409	activeTheme: function() {
 410		if ( this.model.get( 'active' ) ) {
 411			this.$el.addClass( 'active' );
 412		}
 413	},
 414
 415	// Add class of focus to the theme we are focused on.
 416	addFocus: function() {
 417		var $themeToFocus = ( $( ':focus' ).hasClass( 'theme' ) ) ? $( ':focus' ) : $(':focus').parents('.theme');
 418
 419		$('.theme.focus').removeClass('focus');
 420		$themeToFocus.addClass('focus');
 421	},
 422
 423	// Single theme overlay screen
 424	// It's shown when clicking a theme
 425	expand: function( event ) {
 426		var self = this;
 427
 428		event = event || window.event;
 429
 430		// 'enter' and 'space' keys expand the details view when a theme is :focused
 431		if ( event.type === 'keydown' && ( event.which !== 13 && event.which !== 32 ) ) {
 432			return;
 433		}
 434
 435		// Bail if the user scrolled on a touch device
 436		if ( this.touchDrag === true ) {
 437			return this.touchDrag = false;
 438		}
 439
 440		// Prevent the modal from showing when the user clicks
 441		// one of the direct action buttons
 442		if ( $( event.target ).is( '.theme-actions a' ) ) {
 443			return;
 444		}
 445
 446		// Prevent the modal from showing when the user clicks one of the direct action buttons.
 447		if ( $( event.target ).is( '.theme-actions a, .update-message, .button-link, .notice-dismiss' ) ) {
 448			return;
 449		}
 450
 451		// Set focused theme to current element
 452		themes.focusedTheme = this.$el;
 453
 454		this.trigger( 'theme:expand', self.model.cid );
 455	},
 456
 457	preventExpand: function() {
 458		this.touchDrag = true;
 459	},
 460
 461	preview: function( event ) {
 462		var self = this,
 463			current, preview;
 464
 465		event = event || window.event;
 466
 467		// Bail if the user scrolled on a touch device
 468		if ( this.touchDrag === true ) {
 469			return this.touchDrag = false;
 470		}
 471
 472		// Allow direct link path to installing a theme.
 473		if ( $( event.target ).not( '.install-theme-preview' ).parents( '.theme-actions' ).length ) {
 474			return;
 475		}
 476
 477		// 'enter' and 'space' keys expand the details view when a theme is :focused
 478		if ( event.type === 'keydown' && ( event.which !== 13 && event.which !== 32 ) ) {
 479			return;
 480		}
 481
 482		// pressing enter while focused on the buttons shouldn't open the preview
 483		if ( event.type === 'keydown' && event.which !== 13 && $( ':focus' ).hasClass( 'button' ) ) {
 484			return;
 485		}
 486
 487		event.preventDefault();
 488
 489		event = event || window.event;
 490
 491		// Set focus to current theme.
 492		themes.focusedTheme = this.$el;
 493
 494		// Construct a new Preview view.
 495		preview = new themes.view.Preview({
 496			model: this.model
 497		});
 498
 499		// Render the view and append it.
 500		preview.render();
 501		this.setNavButtonsState();
 502
 503		// Hide previous/next navigation if there is only one theme
 504		if ( this.model.collection.length === 1 ) {
 505			preview.$el.addClass( 'no-navigation' );
 506		} else {
 507			preview.$el.removeClass( 'no-navigation' );
 508		}
 509
 510		// Append preview
 511		$( 'div.wrap' ).append( preview.el );
 512
 513		// Listen to our preview object
 514		// for `theme:next` and `theme:previous` events.
 515		this.listenTo( preview, 'theme:next', function() {
 516
 517			// Keep local track of current theme model.
 518			current = self.model;
 519
 520			// If we have ventured away from current model update the current model position.
 521			if ( ! _.isUndefined( self.current ) ) {
 522				current = self.current;
 523			}
 524
 525			// Get next theme model.
 526			self.current = self.model.collection.at( self.model.collection.indexOf( current ) + 1 );
 527
 528			// If we have no more themes, bail.
 529			if ( _.isUndefined( self.current ) ) {
 530				self.options.parent.parent.trigger( 'theme:end' );
 531				return self.current = current;
 532			}
 533
 534			preview.model = self.current;
 535
 536			// Render and append.
 537			preview.render();
 538			this.setNavButtonsState();
 539			$( '.next-theme' ).focus();
 540		})
 541		.listenTo( preview, 'theme:previous', function() {
 542
 543			// Keep track of current theme model.
 544			current = self.model;
 545
 546			// Bail early if we are at the beginning of the collection
 547			if ( self.model.collection.indexOf( self.current ) === 0 ) {
 548				return;
 549			}
 550
 551			// If we have ventured away from current model update the current model position.
 552			if ( ! _.isUndefined( self.current ) ) {
 553				current = self.current;
 554			}
 555
 556			// Get previous theme model.
 557			self.current = self.model.collection.at( self.model.collection.indexOf( current ) - 1 );
 558
 559			// If we have no more themes, bail.
 560			if ( _.isUndefined( self.current ) ) {
 561				return;
 562			}
 563
 564			preview.model = self.current;
 565
 566			// Render and append.
 567			preview.render();
 568			this.setNavButtonsState();
 569			$( '.previous-theme' ).focus();
 570		});
 571
 572		this.listenTo( preview, 'preview:close', function() {
 573			self.current = self.model;
 574		});
 575	},
 576
 577	// Handles .disabled classes for previous/next buttons in theme installer preview
 578	setNavButtonsState: function() {
 579		var $themeInstaller = $( '.theme-install-overlay' ),
 580			current = _.isUndefined( this.current ) ? this.model : this.current;
 581
 582		// Disable previous at the zero position
 583		if ( 0 === this.model.collection.indexOf( current ) ) {
 584			$themeInstaller.find( '.previous-theme' ).addClass( 'disabled' );
 585		}
 586
 587		// Disable next if the next model is undefined
 588		if ( _.isUndefined( this.model.collection.at( this.model.collection.indexOf( current ) + 1 ) ) ) {
 589			$themeInstaller.find( '.next-theme' ).addClass( 'disabled' );
 590		}
 591	},
 592
 593	installTheme: function( event ) {
 594		var _this = this;
 595
 596		event.preventDefault();
 597
 598		wp.updates.maybeRequestFilesystemCredentials( event );
 599
 600		$( document ).on( 'wp-theme-install-success', function( event, response ) {
 601			if ( _this.model.get( 'id' ) === response.slug ) {
 602				_this.model.set( { 'installed': true } );
 603			}
 604		} );
 605
 606		wp.updates.installTheme( {
 607			slug: $( event.target ).data( 'slug' )
 608		} );
 609	},
 610
 611	updateTheme: function( event ) {
 612		var _this = this;
 613		event.preventDefault();
 614
 615		wp.updates.maybeRequestFilesystemCredentials( event );
 616
 617		$( document ).on( 'wp-theme-update-success', function( event, response ) {
 618			_this.model.off( 'change', _this.render, _this );
 619			if ( _this.model.get( 'id' ) === response.slug ) {
 620				_this.model.set( {
 621					hasUpdate: false,
 622					version: response.newVersion
 623				} );
 624			}
 625			_this.model.on( 'change', _this.render, _this );
 626		} );
 627
 628		wp.updates.updateTheme( {
 629			slug: $( event.target ).parents( 'div.theme' ).first().data( 'slug' )
 630		} );
 631	}
 632});
 633
 634// Theme Details view
 635// Set ups a modal overlay with the expanded theme data
 636themes.view.Details = wp.Backbone.View.extend({
 637
 638	// Wrap theme data on a div.theme element
 639	className: 'theme-overlay',
 640
 641	events: {
 642		'click': 'collapse',
 643		'click .delete-theme': 'deleteTheme',
 644		'click .left': 'previousTheme',
 645		'click .right': 'nextTheme',
 646		'click #update-theme': 'updateTheme'
 647	},
 648
 649	// The HTML template for the theme overlay
 650	html: themes.template( 'theme-single' ),
 651
 652	render: function() {
 653		var data = this.model.toJSON();
 654		this.$el.html( this.html( data ) );
 655		// Renders active theme styles
 656		this.activeTheme();
 657		// Set up navigation events
 658		this.navigation();
 659		// Checks screenshot size
 660		this.screenshotCheck( this.$el );
 661		// Contain "tabbing" inside the overlay
 662		this.containFocus( this.$el );
 663	},
 664
 665	// Adds a class to the currently active theme
 666	// and to the overlay in detailed view mode
 667	activeTheme: function() {
 668		// Check the model has the active property
 669		this.$el.toggleClass( 'active', this.model.get( 'active' ) );
 670	},
 671
 672	// Set initial focus and constrain tabbing within the theme browser modal.
 673	containFocus: function( $el ) {
 674
 675		// Set initial focus on the primary action control.
 676		_.delay( function() {
 677			$( '.theme-wrap a.button-primary:visible' ).focus();
 678		}, 100 );
 679
 680		// Constrain tabbing within the modal.
 681		$el.on( 'keydown.wp-themes', function( event ) {
 682			var $firstFocusable = $el.find( '.theme-header button:not(.disabled)' ).first(),
 683				$lastFocusable = $el.find( '.theme-actions a:visible' ).last();
 684
 685			// Check for the Tab key.
 686			if ( 9 === event.which ) {
 687				if ( $firstFocusable[0] === event.target && event.shiftKey ) {
 688					$lastFocusable.focus();
 689					event.preventDefault();
 690				} else if ( $lastFocusable[0] === event.target && ! event.shiftKey ) {
 691					$firstFocusable.focus();
 692					event.preventDefault();
 693				}
 694			}
 695		});
 696	},
 697
 698	// Single theme overlay screen
 699	// It's shown when clicking a theme
 700	collapse: function( event ) {
 701		var self = this,
 702			scroll;
 703
 704		event = event || window.event;
 705
 706		// Prevent collapsing detailed view when there is only one theme available
 707		if ( themes.data.themes.length === 1 ) {
 708			return;
 709		}
 710
 711		// Detect if the click is inside the overlay
 712		// and don't close it unless the target was
 713		// the div.back button
 714		if ( $( event.target ).is( '.theme-backdrop' ) || $( event.target ).is( '.close' ) || event.keyCode === 27 ) {
 715
 716			// Add a temporary closing class while overlay fades out
 717			$( 'body' ).addClass( 'closing-overlay' );
 718
 719			// With a quick fade out animation
 720			this.$el.fadeOut( 130, function() {
 721				// Clicking outside the modal box closes the overlay
 722				$( 'body' ).removeClass( 'closing-overlay' );
 723				// Handle event cleanup
 724				self.closeOverlay();
 725
 726				// Get scroll position to avoid jumping to the top
 727				scroll = document.body.scrollTop;
 728
 729				// Clean the url structure
 730				themes.router.navigate( themes.router.baseUrl( '' ) );
 731
 732				// Restore scroll position
 733				document.body.scrollTop = scroll;
 734
 735				// Return focus to the theme div
 736				if ( themes.focusedTheme ) {
 737					themes.focusedTheme.focus();
 738				}
 739			});
 740		}
 741	},
 742
 743	// Handles .disabled classes for next/previous buttons
 744	navigation: function() {
 745
 746		// Disable Left/Right when at the start or end of the collection
 747		if ( this.model.cid === this.model.collection.at(0).cid ) {
 748			this.$el.find( '.left' )
 749				.addClass( 'disabled' )
 750				.prop( 'disabled', true );
 751		}
 752		if ( this.model.cid === this.model.collection.at( this.model.collection.length - 1 ).cid ) {
 753			this.$el.find( '.right' )
 754				.addClass( 'disabled' )
 755				.prop( 'disabled', true );
 756		}
 757	},
 758
 759	// Performs the actions to effectively close
 760	// the theme details overlay
 761	closeOverlay: function() {
 762		$( 'body' ).removeClass( 'modal-open' );
 763		this.remove();
 764		this.unbind();
 765		this.trigger( 'theme:collapse' );
 766	},
 767
 768	updateTheme: function( event ) {
 769		var _this = this;
 770		event.preventDefault();
 771
 772		wp.updates.maybeRequestFilesystemCredentials( event );
 773
 774		$( document ).on( 'wp-theme-update-success', function( event, response ) {
 775			if ( _this.model.get( 'id' ) === response.slug ) {
 776				_this.model.set( {
 777					hasUpdate: false,
 778					version: response.newVersion
 779				} );
 780			}
 781			_this.render();
 782		} );
 783
 784		wp.updates.updateTheme( {
 785			slug: $( event.target ).data( 'slug' )
 786		} );
 787	},
 788
 789	deleteTheme: function( event ) {
 790		var _this = this,
 791		    _collection = _this.model.collection,
 792		    _themes = themes;
 793		event.preventDefault();
 794
 795		// Confirmation dialog for deleting a theme.
 796		if ( ! window.confirm( wp.themes.data.settings.confirmDelete ) ) {
 797			return;
 798		}
 799
 800		wp.updates.maybeRequestFilesystemCredentials( event );
 801
 802		$( document ).one( 'wp-theme-delete-success', function( event, response ) {
 803			_this.$el.find( '.close' ).trigger( 'click' );
 804			$( '[data-slug="' + response.slug + '"' ).css( { backgroundColor:'#faafaa' } ).fadeOut( 350, function() {
 805				$( this ).remove();
 806				_themes.data.themes = _.without( _themes.data.themes, _.findWhere( _themes.data.themes, { id: response.slug } ) );
 807
 808				$( '.wp-filter-search' ).val( '' );
 809				_collection.doSearch( '' );
 810				_collection.remove( _this.model );
 811				_collection.trigger( 'themes:update' );
 812			} );
 813		} );
 814
 815		wp.updates.deleteTheme( {
 816			slug: this.model.get( 'id' )
 817		} );
 818	},
 819
 820	nextTheme: function() {
 821		var self = this;
 822		self.trigger( 'theme:next', self.model.cid );
 823		return false;
 824	},
 825
 826	previousTheme: function() {
 827		var self = this;
 828		self.trigger( 'theme:previous', self.model.cid );
 829		return false;
 830	},
 831
 832	// Checks if the theme screenshot is the old 300px width version
 833	// and adds a corresponding class if it's true
 834	screenshotCheck: function( el ) {
 835		var screenshot, image;
 836
 837		screenshot = el.find( '.screenshot img' );
 838		image = new Image();
 839		image.src = screenshot.attr( 'src' );
 840
 841		// Width check
 842		if ( image.width && image.width <= 300 ) {
 843			el.addClass( 'small-screenshot' );
 844		}
 845	}
 846});
 847
 848// Theme Preview view
 849// Set ups a modal overlay with the expanded theme data
 850themes.view.Preview = themes.view.Details.extend({
 851
 852	className: 'wp-full-overlay expanded',
 853	el: '.theme-install-overlay',
 854
 855	events: {
 856		'click .close-full-overlay': 'close',
 857		'click .collapse-sidebar': 'collapse',
 858		'click .devices button': 'previewDevice',
 859		'click .previous-theme': 'previousTheme',
 860		'click .next-theme': 'nextTheme',
 861		'keyup': 'keyEvent',
 862		'click .theme-install': 'installTheme'
 863	},
 864
 865	// The HTML template for the theme preview
 866	html: themes.template( 'theme-preview' ),
 867
 868	render: function() {
 869		var self = this, currentPreviewDevice,
 870			data = this.model.toJSON();
 871
 872		this.$el.removeClass( 'iframe-ready' ).html( this.html( data ) );
 873
 874		currentPreviewDevice = this.$el.data( 'current-preview-device' );
 875		if ( currentPreviewDevice ) {
 876			self.tooglePreviewDeviceButtons( currentPreviewDevice );
 877		}
 878
 879		themes.router.navigate( themes.router.baseUrl( themes.router.themePath + this.model.get( 'id' ) ), { replace: true } );
 880
 881		this.$el.fadeIn( 200, function() {
 882			$( 'body' ).addClass( 'theme-installer-active full-overlay-active' );
 883			$( '.close-full-overlay' ).focus();
 884		});
 885
 886		this.$el.find( 'iframe' ).one( 'load', function() {
 887			self.iframeLoaded();
 888		});
 889	},
 890
 891	iframeLoaded: function() {
 892		this.$el.addClass( 'iframe-ready' );
 893	},
 894
 895	close: function() {
 896		this.$el.fadeOut( 200, function() {
 897			$( 'body' ).removeClass( 'theme-installer-active full-overlay-active' );
 898
 899			// Return focus to the theme div
 900			if ( themes.focusedTheme ) {
 901				themes.focusedTheme.focus();
 902			}
 903		}).removeClass( 'iframe-ready' );
 904
 905		themes.router.navigate( themes.router.baseUrl( '' ) );
 906		this.trigger( 'preview:close' );
 907		this.undelegateEvents();
 908		this.unbind();
 909		return false;
 910	},
 911
 912	collapse: function( event ) {
 913		var $button = $( event.currentTarget );
 914		if ( 'true' === $button.attr( 'aria-expanded' ) ) {
 915			$button.attr({ 'aria-expanded': 'false', 'aria-label': l10n.expandSidebar });
 916		} else {
 917			$button.attr({ 'aria-expanded': 'true', 'aria-label': l10n.collapseSidebar });
 918		}
 919
 920		this.$el.toggleClass( 'collapsed' ).toggleClass( 'expanded' );
 921		return false;
 922	},
 923
 924	previewDevice: function( event ) {
 925		var device = $( event.currentTarget ).data( 'device' );
 926
 927		this.$el
 928			.removeClass( 'preview-desktop preview-tablet preview-mobile' )
 929			.addClass( 'preview-' + device )
 930			.data( 'current-preview-device', device );
 931
 932		this.tooglePreviewDeviceButtons( device );
 933	},
 934
 935	tooglePreviewDeviceButtons: function( newDevice ) {
 936		var $devices = $( '.wp-full-overlay-footer .devices' );
 937
 938		$devices.find( 'button' )
 939			.removeClass( 'active' )
 940			.attr( 'aria-pressed', false );
 941
 942		$devices.find( 'button.preview-' + newDevice )
 943			.addClass( 'active' )
 944			.attr( 'aria-pressed', true );
 945	},
 946
 947	keyEvent: function( event ) {
 948		// The escape key closes the preview
 949		if ( event.keyCode === 27 ) {
 950			this.undelegateEvents();
 951			this.close();
 952		}
 953		// The right arrow key, next theme
 954		if ( event.keyCode === 39 ) {
 955			_.once( this.nextTheme() );
 956		}
 957
 958		// The left arrow key, previous theme
 959		if ( event.keyCode === 37 ) {
 960			this.previousTheme();
 961		}
 962	},
 963
 964	installTheme: function( event ) {
 965		var _this   = this,
 966		    $target = $( event.target );
 967		event.preventDefault();
 968
 969		if ( $target.hasClass( 'disabled' ) ) {
 970			return;
 971		}
 972
 973		wp.updates.maybeRequestFilesystemCredentials( event );
 974
 975		$( document ).on( 'wp-theme-install-success', function() {
 976			_this.model.set( { 'installed': true } );
 977		} );
 978
 979		wp.updates.installTheme( {
 980			slug: $target.data( 'slug' )
 981		} );
 982	}
 983});
 984
 985// Controls the rendering of div.themes,
 986// a wrapper that will hold all the theme elements
 987themes.view.Themes = wp.Backbone.View.extend({
 988
 989	className: 'themes wp-clearfix',
 990	$overlay: $( 'div.theme-overlay' ),
 991
 992	// Number to keep track of scroll position
 993	// while in theme-overlay mode
 994	index: 0,
 995
 996	// The theme count element
 997	count: $( '.wrap .theme-count' ),
 998
 999	// The live themes count
1000	liveThemeCount: 0,
1001
1002	initialize: function( options ) {
1003		var self = this;
1004
1005		// Set up parent
1006		this.parent = options.parent;
1007
1008		// Set current view to [grid]
1009		this.setView( 'grid' );
1010
1011		// Move the active theme to the beginning of the collection
1012		self.currentTheme();
1013
1014		// When the collection is updated by user input...
1015		this.listenTo( self.collection, 'themes:update', function() {
1016			self.parent.page = 0;
1017			self.currentTheme();
1018			self.render( this );
1019		} );
1020
1021		// Update theme count to full result set when available.
1022		this.listenTo( self.collection, 'query:success', function( count ) {
1023			if ( _.isNumber( count ) ) {
1024				self.count.text( count );
1025				self.announceSearchResults( count );
1026			} else {
1027				self.count.text( self.collection.length );
1028				self.announceSearchResults( self.collection.length );
1029			}
1030		});
1031
1032		this.listenTo( self.collection, 'query:empty', function() {
1033			$( 'body' ).addClass( 'no-results' );
1034		});
1035
1036		this.listenTo( this.parent, 'theme:scroll', function() {
1037			self.renderThemes( self.parent.page );
1038		});
1039
1040		this.listenTo( this.parent, 'theme:close', function() {
1041			if ( self.overlay ) {
1042				self.overlay.closeOverlay();
1043			}
1044		} );
1045
1046		// Bind keyboard events.
1047		$( 'body' ).on( 'keyup', function( event ) {
1048			if ( ! self.overlay ) {
1049				return;
1050			}
1051
1052			// Bail if the filesystem credentials dialog is shown.
1053			if ( $( '#request-filesystem-credentials-dialog' ).is( ':visible' ) ) {
1054				return;
1055			}
1056
1057			// Pressing the right arrow key fires a theme:next event
1058			if ( event.keyCode === 39 ) {
1059				self.overlay.nextTheme();
1060			}
1061
1062			// Pressing the left arrow key fires a theme:previous event
1063			if ( event.keyCode === 37 ) {
1064				self.overlay.previousTheme();
1065			}
1066
1067			// Pressing the escape key fires a theme:collapse event
1068			if ( event.keyCode === 27 ) {
1069				self.overlay.collapse( event );
1070			}
1071		});
1072	},
1073
1074	// Manages rendering of theme pages
1075	// and keeping theme count in sync
1076	render: function() {
1077		// Clear the DOM, please
1078		this.$el.empty();
1079
1080		// If the user doesn't have switch capabilities
1081		// or there is only one theme in the collection
1082		// render the detailed view of the active theme
1083		if ( themes.data.themes.length === 1 ) {
1084
1085			// Constructs the view
1086			this.singleTheme = new themes.view.Details({
1087				model: this.collection.models[0]
1088			});
1089
1090			// Render and apply a 'single-theme' class to our container
1091			this.singleTheme.render();
1092			this.$el.addClass( 'single-theme' );
1093			this.$el.append( this.singleTheme.el );
1094		}
1095
1096		// Generate the themes
1097		// Using page instance
1098		// While checking the collection has items
1099		if ( this.options.collection.size() > 0 ) {
1100			this.renderThemes( this.parent.page );
1101		}
1102
1103		// Display a live theme count for the collection
1104		this.liveThemeCount = this.collection.count ? this.collection.count : this.collection.length;
1105		this.count.text( this.liveThemeCount );
1106
1107		/*
1108		 * In the theme installer the themes count is already announced
1109		 * because `announceSearchResults` is called on `query:success`.
1110		 */
1111		if ( ! themes.isInstall ) {
1112			this.announceSearchResults( this.liveThemeCount );
1113		}
1114	},
1115
1116	// Iterates through each instance of the collection
1117	// and renders each theme module
1118	renderThemes: function( page ) {
1119		var self = this;
1120
1121		self.instance = self.collection.paginate( page );
1122
1123		// If we have no more themes bail
1124		if ( self.instance.size() === 0 ) {
1125			// Fire a no-more-themes event.
1126			this.parent.trigger( 'theme:end' );
1127			return;
1128		}
1129
1130		// Make sure the add-new stays at the end
1131		if ( ! themes.isInstall && page >= 1 ) {
1132			$( '.add-new-theme' ).remove();
1133		}
1134
1135		// Loop through the themes and setup each theme view
1136		self.instance.each( function( theme ) {
1137			self.theme = new themes.view.Theme({
1138				model: theme,
1139				parent: self
1140			});
1141
1142			// Render the views...
1143			self.theme.render();
1144			// and append them to div.themes
1145			self.$el.append( self.theme.el );
1146
1147			// Binds to theme:expand to show the modal box
1148			// with the theme details
1149			self.listenTo( self.theme, 'theme:expand', self.expand, self );
1150		});
1151
1152		// 'Add new theme' element shown at the end of the grid
1153		if ( ! themes.isInstall && themes.data.settings.canInstall ) {
1154			this.$el.append( '<div class="theme add-new-theme"><a href="' + themes.data.settings.installURI + '"><div class="theme-screenshot"><span></span></div><h2 class="theme-name">' + l10n.addNew + '</h2></a></div>' );
1155		}
1156
1157		this.parent.page++;
1158	},
1159
1160	// Grabs current theme and puts it at the beginning of the collection
1161	currentTheme: function() {
1162		var self = this,
1163			current;
1164
1165		current = self.collection.findWhere({ active: true });
1166
1167		// Move the active theme to the beginning of the collection
1168		if ( current ) {
1169			self.collection.remove( current );
1170			self.collection.add( current, { at:0 } );
1171		}
1172	},
1173
1174	// Sets current view
1175	setView: function( view ) {
1176		return view;
1177	},
1178
1179	// Renders the overlay with the ThemeDetails view
1180	// Uses the current model data
1181	expand: function( id ) {
1182		var self = this, $card, $modal;
1183
1184		// Set the current theme model
1185		this.model = self.collection.get( id );
1186
1187		// Trigger a route update for the current model
1188		themes.router.navigate( themes.router.baseUrl( themes.router.themePath + this.model.id ) );
1189
1190		// Sets this.view to 'detail'
1191		this.setView( 'detail' );
1192		$( 'body' ).addClass( 'modal-open' );
1193
1194		// Set up the theme details view
1195		this.overlay = new themes.view.Details({
1196			model: self.model
1197		});
1198
1199		this.overlay.render();
1200
1201		if ( this.model.get( 'hasUpdate' ) ) {
1202			$card  = $( '[data-slug="' + this.model.id + '"]' );
1203			$modal = $( this.overlay.el );
1204
1205			if ( $card.find( '.updating-message' ).length ) {
1206				$modal.find( '.notice-warning h3' ).remove();
1207				$modal.find( '.notice-warning' )
1208					.removeClass( 'notice-large' )
1209					.addClass( 'updating-message' )
1210					.find( 'p' ).text( wp.updates.l10n.updating );
1211			} else if ( $card.find( '.notice-error' ).length ) {
1212				$modal.find( '.notice-warning' ).remove();
1213			}
1214		}
1215
1216		this.$overlay.html( this.overlay.el );
1217
1218		// Bind to theme:next and theme:previous
1219		// triggered by the arrow keys
1220		//
1221		// Keep track of the current model so we
1222		// can infer an index position
1223		this.listenTo( this.overlay, 'theme:next', function() {
1224			// Renders the next theme on the overlay
1225			self.next( [ self.model.cid ] );
1226
1227		})
1228		.listenTo( this.overlay, 'theme:previous', function() {
1229			// Renders the previous theme on the overlay
1230			self.previous( [ self.model.cid ] );
1231		});
1232	},
1233
1234	// This method renders the next theme on the overlay modal
1235	// based on the current position in the collection
1236	// @params [model cid]
1237	next: function( args ) {
1238		var self = this,
1239			model, nextModel;
1240
1241		// Get the current theme
1242		model = self.collection.get( args[0] );
1243		// Find the next model within the collection
1244		nextModel = self.collection.at( self.collection.indexOf( model ) + 1 );
1245
1246		// Sanity check which also serves as a boundary test
1247		if ( nextModel !== undefined ) {
1248
1249			// We have a new theme...
1250			// Close the overlay
1251			this.overlay.closeOverlay();
1252
1253			// Trigger a route update for the current model
1254			self.theme.trigger( 'theme:expand', nextModel.cid );
1255
1256		}
1257	},
1258
1259	// This method renders the previous theme on the overlay modal
1260	// based on the current position in the collection
1261	// @params [model cid]
1262	previous: function( args ) {
1263		var self = this,
1264			model, previousModel;
1265
1266		// Get the current theme
1267		model = self.collection.get( args[0] );
1268		// Find the previous model within the collection
1269		previousModel = self.collection.at( self.collection.indexOf( model ) - 1 );
1270
1271		if ( previousModel !== undefined ) {
1272
1273			// We have a new theme...
1274			// Close the overlay
1275			this.overlay.closeOverlay();
1276
1277			// Trigger a route update for the current model
1278			self.theme.trigger( 'theme:expand', previousModel.cid );
1279
1280		}
1281	},
1282
1283	// Dispatch audible search results feedback message
1284	announceSearchResults: function( count ) {
1285		if ( 0 === count ) {
1286			wp.a11y.speak( l10n.noThemesFound );
1287		} else {
1288			wp.a11y.speak( l10n.themesFound.replace( '%d', count ) );
1289		}
1290	}
1291});
1292
1293// Search input view controller.
1294themes.view.Search = wp.Backbone.View.extend({
1295
1296	tagName: 'input',
1297	className: 'wp-filter-search',
1298	id: 'wp-filter-search-input',
1299	searching: false,
1300
1301	attributes: {
1302		placeholder: l10n.searchPlaceholder,
1303		type: 'search',
1304		'aria-describedby': 'live-search-desc'
1305	},
1306
1307	events: {
1308		'input': 'search',
1309		'keyup': 'search',
1310		'blur': 'pushState'
1311	},
1312
1313	initialize: function( options ) {
1314
1315		this.parent = options.parent;
1316
1317		this.listenTo( this.parent, 'theme:close', function() {
1318			this.searching = false;
1319		} );
1320
1321	},
1322
1323	search: function( event ) {
1324		// Clear on escape.
1325		if ( event.type === 'keyup' && event.which === 27 ) {
1326			event.target.value = '';
1327		}
1328
1329		/**
1330		 * Since doSearch is debounced, it will only run when user input comes to a rest
1331		 */
1332		this.doSearch( event );
1333	},
1334
1335	// Runs a search on the theme collection.
1336	doSearch: _.debounce( function( event ) {
1337		var options = {};
1338
1339		this.collection.doSearch( event.target.value );
1340
1341		// if search is initiated and key is not return
1342		if ( this.searching && event.which !== 13 ) {
1343			options.replace = true;
1344		} else {
1345			this.searching = true;
1346		}
1347
1348		// Update the URL hash
1349		if ( event.target.value ) {
1350			themes.router.navigate( themes.router.baseUrl( themes.router.searchPath + event.target.value ), options );
1351		} else {
1352			themes.router.navigate( themes.router.baseUrl( '' ) );
1353		}
1354	}, 500 ),
1355
1356	pushState: function( event ) {
1357		var url = themes.router.baseUrl( '' );
1358
1359		if ( event.target.value ) {
1360			url = themes.router.baseUrl( themes.router.searchPath + event.target.value );
1361		}
1362
1363		this.searching = false;
1364		themes.router.navigate( url );
1365
1366	}
1367});
1368
1369// Sets up the routes events for relevant url queries
1370// Listens to [theme] and [search] params
1371themes.Router = Backbone.Router.extend({
1372
1373	routes: {
1374		'themes.php?theme=:slug': 'theme',
1375		'themes.php?search=:query': 'search',
1376		'themes.php?s=:query': 'search',
1377		'themes.php': 'themes',
1378		'': 'themes'
1379	},
1380
1381	baseUrl: function( url ) {
1382		return 'themes.php' + url;
1383	},
1384
1385	themePath: '?theme=',
1386	searchPath: '?search=',
1387
1388	search: function( query ) {
1389		$( '.wp-filter-search' ).val( query );
1390	},
1391
1392	themes: function() {
1393		$( '.wp-filter-search' ).val( '' );
1394	},
1395
1396	navigate: function() {
1397		if ( Backbone.history._hasPushState ) {
1398			Backbone.Router.prototype.navigate.apply( this, arguments );
1399		}
1400	}
1401
1402});
1403
1404// Execute and setup the application
1405themes.Run = {
1406	init: function() {
1407		// Initializes the blog's theme library view
1408		// Create a new collection with data
1409		this.themes = new themes.Collection( themes.data.themes );
1410
1411		// Set up the view
1412		this.view = new themes.view.Appearance({
1413			collection: this.themes
1414		});
1415
1416		this.render();
1417	},
1418
1419	render: function() {
1420
1421		// Render results
1422		this.view.render();
1423		this.routes();
1424
1425		Backbone.history.start({
1426			root: themes.data.settings.adminUrl,
1427			pushState: true,
1428			hashChange: false
1429		});
1430	},
1431
1432	routes: function() {
1433		var self = this;
1434		// Bind to our global thx object
1435		// so that the object is available to sub-views
1436		themes.router = new themes.Router();
1437
1438		// Handles theme details route event
1439		themes.router.on( 'route:theme', function( slug ) {
1440			self.view.view.expand( slug );
1441		});
1442
1443		themes.router.on( 'route:themes', function() {
1444			self.themes.doSearch( '' );
1445			self.view.trigger( 'theme:close' );
1446		});
1447
1448		// Handles search route event
1449		themes.router.on( 'route:search', function() {
1450			$( '.wp-filter-search' ).trigger( 'keyup' );
1451		});
1452
1453		this.extraRoutes();
1454	},
1455
1456	extraRoutes: function() {
1457		return false;
1458	}
1459};
1460
1461// Extend the main Search view
1462themes.view.InstallerSearch =  themes.view.Search.extend({
1463
1464	events: {
1465		'input': 'search',
1466		'keyup': 'search'
1467	},
1468
1469	terms: '',
1470
1471	// Handles Ajax request for searching through themes in public repo
1472	search: function( event ) {
1473
1474		// Tabbing or reverse tabbing into the search input shouldn't trigger a search
1475		if ( event.type === 'keyup' && ( event.which === 9 || event.which === 16 ) ) {
1476			return;
1477		}
1478
1479		this.collection = this.options.parent.view.collection;
1480
1481		// Clear on escape.
1482		if ( event.type === 'keyup' && event.which === 27 ) {
1483			event.target.value = '';
1484		}
1485
1486		this.doSearch( event.target.value );
1487	},
1488
1489	doSearch: _.debounce( function( value ) {
1490		var request = {};
1491
1492		// Don't do anything if the search terms haven't changed.
1493		if ( this.terms === value ) {
1494			return;
1495		}
1496
1497		// Updates terms with the value passed.
1498		this.terms = value;
1499
1500		request.search = value;
1501
1502		// Intercept an [author] search.
1503		//
1504		// If input value starts with `author:` send a request
1505		// for `author` instead of a regular `search`
1506		if ( value.substring( 0, 7 ) === 'author:' ) {
1507			request.search = '';
1508			request.author = value.slice( 7 );
1509		}
1510
1511		// Intercept a [tag] search.
1512		//
1513		// If input value starts with `tag:` send a request
1514		// for `tag` instead of a regular `search`
1515		if ( value.substring( 0, 4 ) === 'tag:' ) {
1516			request.search = '';
1517			request.tag = [ value.slice( 4 ) ];
1518		}
1519
1520		$( '.filter-links li > a.current' ).removeClass( 'current' );
1521		$( 'body' ).removeClass( 'show-filters filters-applied show-favorites-form' );
1522
1523		// Get the themes by sending Ajax POST request to api.wordpress.org/themes
1524		// or searching the local cache
1525		this.collection.query( request );
1526
1527		// Set route
1528		themes.router.navigate( themes.router.baseUrl( themes.router.searchPath + value ), { replace: true } );
1529	}, 500 )
1530});
1531
1532themes.view.Installer = themes.view.Appearance.extend({
1533
1534	el: '#wpbody-content .wrap',
1535
1536	// Register events for sorting and filters in theme-navigation
1537	events: {
1538		'click .filter-links li > a': 'onSort',
1539		'click .theme-filter': 'onFilter',
1540		'click .drawer-toggle': 'moreFilters',
1541		'click .filter-drawer .apply-filters': 'applyFilters',
1542		'click .filter-group [type="checkbox"]': 'addFilter',
1543		'click .filter-drawer .clear-filters': 'clearFilters',
1544		'click .filtered-by': 'backToFilters',
1545		'click .favorites-form-submit' : 'saveUsername',
1546		'keyup #wporg-username-input': 'saveUsername'
1547	},
1548
1549	// Initial render method
1550	render: function() {
1551		var self = this;
1552
1553		this.search();
1554		this.uploader();
1555
1556		this.collection = new themes.Collection();
1557
1558		// Bump `collection.currentQuery.page` and request more themes if we hit the end of the page.
1559		this.listenTo( this, 'theme:end', function() {
1560
1561			// Make sure we are not already loading
1562			if ( self.collection.loadingThemes ) {
1563				return;
1564			}
1565
1566			// Set loadingThemes to true and bump page instance of currentQuery.
1567			self.collection.loadingThemes = true;
1568			self.collection.currentQuery.page++;
1569
1570			// Use currentQuery.page to build the themes request.
1571			_.extend( self.collection.currentQuery.request, { page: self.collection.currentQuery.page } );
1572			self.collection.query( self.collection.currentQuery.request );
1573		});
1574
1575		this.listenTo( this.collection, 'query:success', function() {
1576			$( 'body' ).removeClass( 'loading-content' );
1577			$( '.theme-browser' ).find( 'div.error' ).remove();
1578		});
1579
1580		this.listenTo( this.collection, 'query:fail', function() {
1581			$( 'body' ).removeClass( 'loading-content' );
1582			$( '.theme-browser' ).find( 'div.error' ).remove();
1583			$( '.theme-browser' ).find( 'div.themes' ).before( '<div class="error"><p>' + l10n.error + '</p></div>' );
1584		});
1585
1586		if ( this.view ) {
1587			this.view.remove();
1588		}
1589
1590		// Set ups the view and passes the section argument
1591		this.view = new themes.view.Themes({
1592			collection: this.collection,
1593			parent: this
1594		});
1595
1596		// Reset pagination every time the install view handler is run
1597		this.page = 0;
1598
1599		// Render and append
1600		this.$el.find( '.themes' ).remove();
1601		this.view.render();
1602		this.$el.find( '.theme-browser' ).append( this.view.el ).addClass( 'rendered' );
1603	},
1604
1605	// Handles all the rendering of the public theme directory
1606	browse: function( section ) {
1607		// Create a new collection with the proper theme data
1608		// for each section
1609		this.collection.query( { browse: section } );
1610	},
1611
1612	// Sorting navigation
1613	onSort: function( event ) {
1614		var $el = $( event.target ),
1615			sort = $el.data( 'sort' );
1616
1617		event.preventDefault();
1618
1619		$( 'body' ).removeClass( 'filters-applied show-filters' );
1620
1621		// Bail if this is already active
1622		if ( $el.hasClass( this.activeClass ) ) {
1623			return;
1624		}
1625
1626		this.sort( sort );
1627
1628		// Trigger a router.naviagte update
1629		themes.router.navigate( themes.router.baseUrl( themes.router.browsePath + sort ) );
1630	},
1631
1632	sort: function( sort ) {
1633		this.clearSearch();
1634
1635		$( '.filter-links li > a, .theme-filter' ).removeClass( this.activeClass );
1636		$( '[data-sort="' + sort + '"]' ).addClass( this.activeClass );
1637
1638		if ( 'favorites' === sort ) {
1639			$ ( 'body' ).addClass( 'show-favorites-form' );
1640		} else {
1641			$ ( 'body' ).removeClass( 'show-favorites-form' );
1642		}
1643
1644		this.browse( sort );
1645	},
1646
1647	// Filters and Tags
1648	onFilter: function( event ) {
1649		var request,
1650			$el = $( event.target ),
1651			filter = $el.data( 'filter' );
1652
1653		// Bail if this is already active
1654		if ( $el.hasClass( this.activeClass ) ) {
1655			return;
1656		}
1657
1658		$( '.filter-links li > a, .theme-section' ).removeClass( this.activeClass );
1659		$el.addClass( this.activeClass );
1660
1661		if ( ! filter ) {
1662			return;
1663		}
1664
1665		// Construct the filter request
1666		// using the default values
1667		filter = _.union( [ filter, this.filtersChecked() ] );
1668		request = { tag: [ filter ] };
1669
1670		// Get the themes by sending Ajax POST request to api.wordpress.org/themes
1671		// or searching the local cache
1672		this.collection.query( request );
1673	},
1674
1675	// Clicking on a checkbox to add another filter to the request
1676	addFilter: function() {
1677		this.filtersChecked();
1678	},
1679
1680	// Applying filters triggers a tag request
1681	applyFilters: function( event ) {
1682		var name,
1683			tags = this.filtersChecked(),
1684			request = { tag: tags },
1685			filteringBy = $( '.filtered-by .tags' );
1686
1687		if ( event ) {
1688			event.preventDefault();
1689		}
1690
1691		$( 'body' ).addClass( 'filters-applied' );
1692		$( '.filter-links li > a.current' ).removeClass( 'current' );
1693		filteringBy.empty();
1694
1695		_.each( tags, function( tag ) {
1696			name = $( 'label[for="filter-id-' + tag + '"]' ).text();
1697			filteringBy.append( '<span class="tag">' + name + '</span>' );
1698		});
1699
1700		// Get the themes by sending Ajax POST request to api.wordpress.org/themes
1701		// or searching the local cache
1702		this.collection.query( request );
1703	},
1704
1705	// Save the user's WordPress.org username and get his favorite themes.
1706	saveUsername: function ( event ) {
1707		var username = $( '#wporg-username-input' ).val(),
1708			nonce = $( '#wporg-username-nonce' ).val(),
1709			request = { browse: 'favorites', user: username },
1710			that = this;
1711
1712		if ( event ) {
1713			event.preventDefault();
1714		}
1715
1716		// save username on enter
1717		if ( event.type === 'keyup' && event.which !== 13 ) {
1718			return;
1719		}
1720
1721		return wp.ajax.send( 'save-wporg-username', {
1722			data: {
1723				_wpnonce: nonce,
1724				username: username
1725			},
1726			success: function () {
1727				// Get the themes by sending Ajax POST request to api.wordpress.org/themes
1728				// or searching the local cache
1729				that.collection.query( request );
1730			}
1731		} );
1732	},
1733
1734	// Get the checked filters
1735	// @return {array} of tags or false
1736	filtersChecked: function() {
1737		var items = $( '.filter-group' ).find( ':checkbox' ),
1738			tags = [];
1739
1740		_.each( items.filter( ':checked' ), function( item ) {
1741			tags.push( $( item ).prop( 'value' ) );
1742		});
1743
1744		// When no filters are checked, restore initial state and return
1745		if ( tags.length === 0 ) {
1746			$( '.filter-drawer .apply-filters' ).find( 'span' ).text( '' );
1747			$( '.filter-drawer .clear-filters' ).hide();
1748			$( 'body' ).removeClass( 'filters-applied' );
1749			return false;
1750		}
1751
1752		$( '.filter-drawer .apply-filters' ).find( 'span' ).text( tags.length );
1753		$( '.filter-drawer .clear-filters' ).css( 'display', 'inline-block' );
1754
1755		return tags;
1756	},
1757
1758	activeClass: 'current',
1759
1760	// Overwrite search container class to append search
1761	// in new location
1762	searchContainer: $( '.wp-filter .search-form' ),
1763
1764	/*
1765	 * When users press the "Upload Theme" button, show the upload form in place.
1766	 */
1767	uploader: function() {
1768		var uploadViewToggle = $( '.upload-view-toggle' ),
1769			$body = $( document.body );
1770
1771		uploadViewToggle.on( 'click', function() {
1772			// Toggle the upload view.
1773			$body.toggleClass( 'show-upload-view' );
1774			// Toggle the `aria-expanded` button attribute.
1775			uploadViewToggle.attr( 'aria-expanded', $body.hasClass( 'show-upload-view' ) );
1776		});
1777	},
1778
1779	// Toggle the full filters navigation
1780	moreFilters: function( event ) {
1781		event.preventDefault();
1782
1783		if ( $( 'body' ).hasClass( 'filters-applied' ) ) {
1784			return this.backToFilters();
1785		}
1786
1787		// If the filters section is opened and filters are checked
1788		// run the relevant query collapsing to filtered-by state
1789		if ( $( 'body' ).hasClass( 'show-filters' ) && this.filtersChecked() ) {
1790			return this.addFilter();
1791		}
1792
1793		this.clearSearch();
1794
1795		themes.router.navigate( themes.router.baseUrl( '' ) );
1796		$( 'body' ).toggleClass( 'show-filters' );
1797	},
1798
1799	// Clears all the checked filters
1800	// @uses filtersChecked()
1801	clearFilters: function( event ) {
1802		var items = $( '.filter-group' ).find( ':checkbox' ),
1803			self = this;
1804
1805		event.preventDefault();
1806
1807		_.each( items.filter( ':checked' ), function( item ) {
1808			$( item ).prop( 'checked', false );
1809			return self.filtersChecked();
1810		});
1811	},
1812
1813	backToFilters: function( event ) {
1814		if ( event ) {
1815			event.preventDefault();
1816		}
1817
1818		$( 'body' ).removeClass( 'filters-applied' );
1819	},
1820
1821	clearSearch: function() {
1822		$( '#wp-filter-search-input').val( '' );
1823	}
1824});
1825
1826themes.InstallerRouter = Backbone.Router.extend({
1827	routes: {
1828		'theme-install.php?theme=:slug': 'preview',
1829		'theme-install.php?browse=:sort': 'sort',
1830		'theme-install.php?search=:query': 'search',
1831		'theme-install.php': 'sort'
1832	},
1833
1834	baseUrl: function( url ) {
1835		return 'theme-install.php' + url;
1836	},
1837
1838	themePath: '?theme=',
1839	browsePath: '?browse=',
1840	searchPath: '?search=',
1841
1842	search: function( query ) {
1843		$( '.wp-filter-search' ).val( query );
1844	},
1845
1846	navigate: function() {
1847		if ( Backbone.history._hasPushState ) {
1848			Backbone.Router.prototype.navigate.apply( this, arguments );
1849		}
1850	}
1851});
1852
1853
1854themes.RunInstaller = {
1855
1856	init: function() {
1857		// Set up the view
1858		// Passes the default 'section' as an option
1859		this.view = new themes.view.Installer({
1860			section: 'featured',
1861			SearchView: themes.view.InstallerSearch
1862		});
1863
1864		// Render results
1865		this.render();
1866
1867	},
1868
1869	render: function() {
1870
1871		// Render results
1872		this.view.render();
1873		this.routes();
1874
1875		Backbone.history.start({
1876			root: themes.data.settings.adminUrl,
1877			pushState: true,
1878			hashChange: false
1879		});
1880	},
1881
1882	routes: function() {
1883		var self = this,
1884			request = {};
1885
1886		// Bind to our global `wp.themes` object
1887		// so that the router is available to sub-views
1888		themes.router = new themes.InstallerRouter();
1889
1890		// Handles `theme` route event
1891		// Queries the API for the passed theme slug
1892		themes.router.on( 'route:preview', function( slug ) {
1893			request.theme = slug;
1894			self.view.collection.query( request );
1895			self.view.collection.once( 'update', function() {
1896				self.view.view.theme.preview();
1897			});
1898		});
1899
1900		// Handles sorting / browsing routes
1901		// Also handles the root URL triggering a sort request
1902		// for `featured`, the default view
1903		themes.router.on( 'route:sort', function( sort ) {