Add site-wide search function

This commit is contained in:
Andrew Miner
2015-04-05 18:33:51 -07:00
parent b79fa5a148
commit 136ebb4187
19 changed files with 521 additions and 53 deletions
+7 -2
View File
@@ -36,6 +36,7 @@ Duration.slow = 1200
exports.Event = Event = {} exports.Event = Event = {}
Event.add = 'add' # collection, item... Event.add = 'add' # collection, item...
Event.change = 'change' # model Event.change = 'change' # model
Event.click = 'click' # event
Event.load = {} Event.load = {}
Event.load.started = 'load:started' # controller, url Event.load.started = 'load:started' # controller, url
Event.load.succeeded = 'load:succeeded' # controller, book Event.load.succeeded = 'load:succeeded' # controller, book
@@ -60,8 +61,12 @@ Event.transitionEnd = (->
return eventName if el.style[styleName]? return eventName if el.style[styleName]?
)() )()
exports.Key = Key = {} exports.Key = Key = {}
Key.Return = 13 Key.Enter = 13
Key.Return = 13
Key.Escape = 27
Key.UpArrow = 38
Key.DownArrow = 40
exports.Login = Login = {} exports.Login = Login = {}
Login.authorizeUrl = _.template "https://github.com/login/oauth/authorize" + Login.authorizeUrl = _.template "https://github.com/login/oauth/authorize" +
+14 -8
View File
@@ -13,12 +13,14 @@ views = require '../views'
module.exports = class BaseController extends Backbone.View module.exports = class BaseController extends Backbone.View
constructor: (options={})-> constructor: (options={})->
options.useAnimations ?= true
@tryRefresh = _.debounce @_tryRefresh, 100 @tryRefresh = _.debounce @_tryRefresh, 100
@_model = null @_model = null
@_rendered = false @_rendered = false
@_parent = options.parent @_parent = options.parent
@_children = [] @_children = []
@_useAnimations = options.useAnimations
@_loadTemplate options.templateName @_loadTemplate options.templateName
super options super options
@@ -55,8 +57,12 @@ module.exports = class BaseController extends Backbone.View
logger.verbose => "#{this} refreshing" logger.verbose => "#{this} refreshing"
remove: -> remove: ->
logger.verbose => "#{this} is removing its element from the DOM" if @_useAnimations
@hide => @$el.remove() @hide =>
logger.verbose => "#{this} is removing its element from the DOM"
@$el.remove()
else
@$el.remove()
routeLinkClick: (event)-> routeLinkClick: (event)->
event.preventDefault() event.preventDefault()
@@ -158,14 +164,14 @@ module.exports = class BaseController extends Backbone.View
$renderedEl = $($(@_template(data))[0]) $renderedEl = $($(@_template(data))[0])
@onWillRender() @onWillRender()
@hide() @hide() if @_useAnimations
@unrender() @unrender()
@$el.append $renderedEl.children() @$el.append $renderedEl.children()
@$el.addClass $renderedEl.attr 'class' @$el.addClass $renderedEl.attr 'class'
@$el.data $renderedEl.data() @$el.data $renderedEl.data()
@delegateEvents() @delegateEvents()
@show() if options.show @show() if options.show and @_useAnimations
@_rendered = true @_rendered = true
@onDidRender() @onDidRender()
@@ -5,11 +5,12 @@ Copyright (c) 2015 by Redwood Labs
All rights reserved. All rights reserved.
### ###
BaseController = require './base_controller' BaseController = require './base_controller'
{Duration} = require '../constants' ItemSelectorController = require './item_selector_controller'
{Event} = require '../constants' {Duration} = require '../constants'
{ProductionEnvs} = require '../constants' {Event} = require '../constants'
{Url} = require '../constants' {ProductionEnvs} = require '../constants'
{Url} = require '../constants'
######################################################################################################################## ########################################################################################################################
@@ -17,10 +18,12 @@ module.exports = class HeaderController extends BaseController
constructor: (options={})-> constructor: (options={})->
if not options.client? then throw new Error 'options.client is required' if not options.client? then throw new Error 'options.client is required'
if not options.modPack? then throw new Error 'options.modPack is required'
super options super options
@client = options.client @client = options.client
@_user = options.user @modPack = options.modPack
@_user = options.user
# Event Methods ################################################################################ # Event Methods ################################################################################
@@ -78,6 +81,10 @@ module.exports = class HeaderController extends BaseController
addThisUrl = '//s7.addthis.com/js/300/addthis_widget.js#pubid=ra-54f3c9717b2e530d' addThisUrl = '//s7.addthis.com/js/300/addthis_widget.js#pubid=ra-54f3c9717b2e530d'
$('body').append "<script async src=\"#{addThisUrl}\"></script>" $('body').append "<script async src=\"#{addThisUrl}\"></script>"
@_itemSelector = @addChild ItemSelectorController, '.view__item_selector',
modPack: @modPack
onChoseItem: (itemSlug)=> @_goToItem(itemSlug)
refresh: -> refresh: ->
if @user? if @user?
@$loginName.html "Hello #{router.user.login}!" @$loginName.html "Hello #{router.user.login}!"
@@ -105,3 +112,8 @@ module.exports = class HeaderController extends BaseController
'click a.logo': 'onLogoClicked' 'click a.logo': 'onLogoClicked'
'click .navBar a': 'onNavItemClicked' 'click .navBar a': 'onNavItemClicked'
'click .login a': 'onLoginLinkClicked' 'click .login a': 'onLoginLinkClicked'
# Private Methods ##############################################################################
_goToItem: (itemSlug)->
router.navigate Url.item(itemSlug:itemSlug.item, modSlug:itemSlug.mod), trigger:true
@@ -0,0 +1,185 @@
###
Crafting Guide - item_selector_controller.coffee
Copyright (c) 2015 by Redwood Labs
All rights reserved.
###
BaseController = require './base_controller'
ItemSelector = require '../models/item_selector'
ItemSelectorElementController = require './item_selector_element_controller'
{Duration} = require '../constants'
{Event} = require '../constants'
{Key} = require '../constants'
########################################################################################################################
module.exports = class ItemSelectorController extends BaseController
constructor: (options)->
if not options.modPack? then throw new Error 'options.modPack is required'
options.model ?= new ItemSelector {}, modPack:options.modPack
options.onChoseItem ?= (item)-> # do nothing
options.templateName = 'item_selector'
super options
@modPack = options.modPack
@onChoseItem = options.onChoseItem
@_elementControllers = []
# Event Methods ################################################################################
onHintKeyPress: (event)->
switch event.which
when Key.Escape
@_close()
return false
when Key.Enter
@_chooseSelected()
return false
when Key.DownArrow
@_selectNext()
return false
when Key.UpArrow
@_selectPrevious()
return false
onHintChanged: (event)->
@model.hint = @$hintField.val()
@tryRefresh()
@onResultSelected @_elementControllers[0]
onLaunchButtonClicked: (event)->
event.preventDefault()
@model.hint = ''
@refresh()
$('body').addClass 'stop-scrolling'
@$screen.append @$popup
@$screen.css 'display', 'block'
@$popup.offset @$el.offset()
@$popup.css 'width', @$el.css 'width'
@$popup.off Event.click
@$popup.on Event.click, (event)=> @onPopupClicked(event)
@$popup.removeClass 'hiding'
@$hintField.on 'keyup', (event)=> @onHintKeyPress(event)
@$hintField.on 'input', (event)=> @onHintChanged(event)
@$popup.off Event.transitionEnd
@$popup.one Event.transitionEnd, => @onPopupDisplayed()
@$screen.one Event.click, (event)=> @onScreenClicked(event)
onPopupClicked: (event)->
return false
onPopupDisplayed: ->
@$hintField.focus()
onResultClicked: (controller)->
@_close()
@onChoseItem controller.model.slug
onResultSelected: (controller)->
for c in @_elementControllers
c.selected = false
if controller?
controller.selected = true
onScreenClicked: (event)->
@_close()
return false
# BaseController Overrides #####################################################################
onDidRender: ->
@$screen = $('.view__screen') # find the shared, global screen
@$popup = @$('.view__item_selector_popup')
@$hintField = @$('.view__item_selector_popup input')
@$searchInput = @$('.search input')
@$resultsContainer = @$('.results')
@$popup.detach()
super
refresh: ->
@$hintField.val @model.hint
@_refreshResults()
super
# Backbone.View Overrides ######################################################################
events: ->
return _.extend super,
'click': 'onLaunchButtonClicked'
# Private Methods ##############################################################################
_chooseSelected: ->
for controller in @_elementControllers
if controller.selected
@onResultClicked controller
return
_close: ->
$('body').removeClass 'stop-scrolling'
@$popup.addClass 'hiding'
@$popup.off Event.transitionEnd
@$popup.one Event.transitionEnd, =>
@$screen.css 'display', 'none'
@$popup.detach()
_refreshResults: ->
index = 0
for itemSlug in @model.results
displayModel = @modPack.findItemDisplay itemSlug
controller = @_elementControllers[index]
if not controller?
controller = new ItemSelectorElementController
model: displayModel
onClicked: (c)=> @onResultClicked(c)
onSelected: (c)=> @onResultSelected(c)
controller.render show:false
@_elementControllers[index] = controller
@$resultsContainer.append controller.$el
else
controller.model = displayModel
controller.show()
index += 1
while @_elementControllers.length > index
@_elementControllers.pop().remove()
_selectNext: ->
for i in [0...(@_elementControllers.length - 1)] by 1
controller = @_elementControllers[i]
nextController = @_elementControllers[i + 1]
if controller.selected
controller.selected = false
nextController.selected = true
@_showElement nextController.$el
return
_selectPrevious: ->
for i in [1...@_elementControllers.length] by 1
previousController = @_elementControllers[i - 1]
controller = @_elementControllers[i]
if controller.selected
previousController.selected = true
controller.selected = false
@_showElement previousController.$el
return
_showElement: ($el)->
top = $el.position().top + @$resultsContainer.scrollTop()
@$resultsContainer.animate {scrollTop:top}, duration:Duration.snap
@@ -0,0 +1,79 @@
###
Crafting Guide - item_selector_element_controller.coffee
Copyright (c) 2015 by Redwood Labs
All rights reserved.
###
BaseController = require './base_controller'
{Event} = require '../constants'
########################################################################################################################
module.exports = class ItemSelectorElementController extends BaseController
constructor: (options={})->
if not options.model? then throw new Error 'options.model is required'
options.onClicked ?= (controller)-> # do nothing
options.onSelected ?= (controller)-> # do nothing
options.templateName = 'item_selector_element'
options.useAnimations = false
super options
@onClicked = options.onClicked
@onSelected = options.onSelected
# Property Methods #############################################################################
isSelected: ->
return @_selected
setSelected: (newSelected)->
oldSelected = @_selected
return if newSelected is oldSelected
@_selected = newSelected
@tryRefresh()
@trigger Event.change + ':selected', this, oldSelected, newSelected
@trigger Event.change, this
Object.defineProperties @prototype,
selected: {get:@prototype.isSelected, set:@prototype.setSelected}
# BaseController Overrides #####################################################################
onDidRender: ->
@$icon = @$('img')
@$name = @$('.name p')
@$modName = @$('.modName p')
super
refresh: ->
@$icon.attr 'src', @model.iconUrl
@$name.html @model.itemName
@$modName.html @model.modName
if @_selected
@$el.addClass 'selected'
else
@$el.removeClass 'selected'
super
# Backbone.View Overrides ######################################################################
events: ->
return _.extend super,
'click': '_onClick'
'mouseenter': '_onMouseEnter'
# Private Methods ##############################################################################
_onClick: (event)->
event.preventDefault()
@onClicked this
_onMouseEnter: (event)->
event.preventDefault()
@onSelected this
+84
View File
@@ -0,0 +1,84 @@
###
Crafting Guide - item_selector.coffee
Copyright (c) 2015 by Redwood Labs
All rights reserved.
###
BaseModel = require './base_model'
ItemSlug = require './item_slug'
########################################################################################################################
module.exports = class ItemSelector extends BaseModel
constructor: (attributes={}, options={})->
if not options.modPack? then throw new Error 'options.modPack is required'
super attributes, options
@_maxResults = 100
@_minHintLength = 3
@_modPack = options.modPack
@_results = []
# Property Methods #############################################################################
getHint: ->
return @_hint
setHint: (newHint)->
oldHint = @_hint
return if newHint is oldHint
@_hint = newHint.toLowerCase()
@trigger Event.change + ':hint', this, oldHint, newHint
@_refreshResults()
@trigger Event.change, this
getResults: ->
return @_results
Object.defineProperties @prototype,
hint: {get:@prototype.getHint, set:@prototype.setHint}
results: {get:@prototype.getResults}
# Private Methods ##############################################################################
_isMatch: (name)->
hintIndex = 0
hintLetter = @_hint[hintIndex]
name = name.toLowerCase()
for nameIndex in [0...name.length] by 1
if name.charAt(nameIndex) is hintLetter
hintIndex += 1
return true if hintIndex is @_hint.length
hintLetter = @_hint[hintIndex]
return false
_refreshResults: ->
oldResults = @_results
newResults = {}
count = 0
logger.verbose => "Looking for items which match: #{@_hint}"
if @_hint.length >= @_minHintLength
@_modPack.eachMod (mod)=>
return if count >= @_maxResults
mod.eachName (name, itemSlug)=>
return if count >= @_maxResults
if @_isMatch name
if itemSlug.isQualified
newResults[itemSlug] = itemSlug
count += 1
newResults = _.values(newResults)
newResults.sort ItemSlug.compare
@_results = newResults
@trigger Event.change + ':results', this, oldResults, newResults
+3 -13
View File
@@ -25,20 +25,10 @@ module.exports = class ItemSlug
# Class Methods ################################################################################ # Class Methods ################################################################################
@compare: (a, b)-> @compare: (a, b)->
aIsRequired = a.mod in RequiredMods if a.item isnt b.item
bIsRequired = b.mod in RequiredMods
if aIsRequired isnt bIsRequired
return -1 if aIsRequired
return +1 if bIsRequired
else if a.isQualified isnt b.isQualified
return -1 if a.isQualified
return +1 if b.isQualified
else if a.mod isnt b.mod
return if a.mod < b.mod then -1 else +1
else if a.item isnt b.item
return if a.item < b.item then -1 else +1 return if a.item < b.item then -1 else +1
if a.mod isnt b.mod
return if a.mod < b.mod then -1 else +1
return 0 return 0
@equal: (a, b)-> @equal: (a, b)->
+6 -5
View File
@@ -53,22 +53,23 @@ module.exports = class ModPack extends BaseModel
findItemDisplay: (itemSlug)-> findItemDisplay: (itemSlug)->
if not itemSlug? then throw new Error 'itemSlug is required' if not itemSlug? then throw new Error 'itemSlug is required'
result = {} result = {slug:itemSlug}
item = @findItem itemSlug, includeDisabled:true item = @findItem itemSlug, includeDisabled:true
if item? if item?
result.itemName = item.name
result.itemSlug = item.slug.item
result.modSlug = item.slug.mod result.modSlug = item.slug.mod
result.modVersion = item.modVersion.version result.modVersion = item.modVersion.version
result.itemSlug = item.slug.item
result.itemName = item.name
else else
result.itemName = @findName itemSlug, includeDisabled:true
result.itemSlug = itemSlug.item
result.modSlug = @_mods[0].slug result.modSlug = @_mods[0].slug
result.modVersion = @_mods[0].activeVersion result.modVersion = @_mods[0].activeVersion
result.itemSlug = itemSlug.item
result.itemName = @findName itemSlug, includeDisabled:true
result.craftingUrl = Url.crafting inventoryText:itemSlug.item result.craftingUrl = Url.crafting inventoryText:itemSlug.item
result.iconUrl = Url.itemIcon result result.iconUrl = Url.itemIcon result
result.itemUrl = Url.item result result.itemUrl = Url.item result
result.modName = @getMod(result.modSlug).name
return result return result
findName: (slug, options={})-> findName: (slug, options={})->
+2
View File
@@ -29,3 +29,5 @@
a(data-page="craft", href="/craft/") a(data-page="craft", href="/craft/")
p Craft p Craft
.dot .dot
.view__item_selector
+14
View File
@@ -0,0 +1,14 @@
//-
//- Crafting Guide - item_selector.jade
//-
//- Copyright (c) 2015 by Redwood Labs
//- All rights reserved.
//-
.view__item_selector
p.placeholder find items...
.view__item_selector_popup.hideable.hiding
.search
input
.results
@@ -0,0 +1,11 @@
//-
//- Crafting Guide - item_selector_element.jade
//-
//- Copyright (c) 2015 by Redwood Labs
//- All rights reserved.
//-
.view__item_selector_element
img
.name: p
.modName: p
+5
View File
@@ -130,6 +130,11 @@ All rights reserved.
} }
} }
.stop-scrolling {
height: 100%;
overflow: hidden;
}
.two-thirds-content { .two-thirds-content {
position: relative; width: 66%; position: relative; width: 66%;
margin: 5em 16.5%; margin: 5em 16.5%;
+2
View File
@@ -17,6 +17,8 @@ All rights reserved.
@import 'inventory_table'; @import 'inventory_table';
@import 'item'; @import 'item';
@import 'item_page'; @import 'item_page';
@import 'item_selector';
@import 'item_selector_element';
@import 'login_page'; @import 'login_page';
@import 'minimal_recipe'; @import 'minimal_recipe';
@import 'mod'; @import 'mod';
+51
View File
@@ -0,0 +1,51 @@
/*
Crafting Guide - item_selector.scss
Copyright (C) 2015 by Redwood Labs
All rights reserved.
*/
.view__item_selector {
background: $color-background-panel;
border: 0.1em solid $color-gray-medium;
border-radius: 1.5em;
cursor: pointer;
padding: 0 0.75em;
p.placeholder {
position: relative; top: 50%; width: 100%;
@include transform(translateY(-50%));
color: $color-gray-medium;
font-family: $font-family-input;
font-size: $font-size-normal;
}
}
.view__item_selector_popup {
position: absolute; top: 0; left: 0;
min-height: 5em;
background: $color-background-panel;
border: 0.1em solid $color-gray-medium;
border-radius: 1.5em 1.5em 0 0;
box-shadow: 10px 10px 20px #666;
.search {
border-bottom: 0.1em solid $color-gray-medium;
margin: 1em;
input {
border: none;
outline: none;
width: 100%;
}
}
.results {
position: relative;
max-height: 50em;
overflow-y: auto;
padding-bottom: 1em;
}
}
@@ -0,0 +1,31 @@
/*
Crafting Guide - item_selector_element.scss
Copyright (C) 2015 by Redwood Labs
All rights reserved.
*/
.view__item_selector_element {
height: 3.6em;
padding: 0.3em 1em;
clear: both;
transition: background $animate-fast, color $animate-fast;
&.selected {
background: $color-active-hover;
color: $color-text-light;
}
img {
height: 3.6em; width: 3.6em;
float: left;
margin-right: 1em;
}
.modName p {
color: $color-gray-medium;
font-size: $font-size-small;
margin-top: 0.2em;
}
}
+5 -1
View File
@@ -132,6 +132,10 @@ All rights reserved.
transition: color $animate-normal; transition: color $animate-normal;
} }
} }
.view__item_selector {
position: absolute; top: 0; right: 0; height: 3em; width: 35em;
}
} }
} }
@@ -139,4 +143,4 @@ All rights reserved.
position: fixed; top: 0; right: 0; bottom: 0; left: 0; position: fixed; top: 0; right: 0; bottom: 0; left: 0;
display: none; display: none;
z-index: $layer-screen; z-index: $layer-screen;
} }
+2 -2
View File
@@ -129,9 +129,9 @@ describe 'inventory.coffee', ->
inventory.each (stack)-> slugs.push stack.itemSlug.qualified inventory.each (stack)-> slugs.push stack.itemSlug.qualified
slugs.should.eql [ slugs.should.eql [
'minecraft__boat' 'minecraft__boat'
'buildcraft__stone_gear'
'minecraft__string' 'minecraft__string'
'minecraft__wool' 'minecraft__wool'
'buildcraft__stone_gear'
] ]
it 'ignores qualified slugs', -> it 'ignores qualified slugs', ->
@@ -143,9 +143,9 @@ describe 'inventory.coffee', ->
inventory.each (stack)-> slugs.push stack.itemSlug.qualified inventory.each (stack)-> slugs.push stack.itemSlug.qualified
slugs.should.eql [ slugs.should.eql [
'minecraft__boat' 'minecraft__boat'
'buildcraft__stone_gear'
'minecraft__string' 'minecraft__string'
'minecraft__wool' 'minecraft__wool'
'buildcraft__stone_gear'
] ]
describe 'parse', -> describe 'parse', ->
-14
View File
@@ -35,20 +35,6 @@ describe 'item_slug.coffee', ->
describe 'ItemSlug.compare', -> describe 'ItemSlug.compare', ->
it 'sorts qualified slugs first', ->
a = new ItemSlug 'alpha'
b = new ItemSlug 'bravo', 'charlie'
ItemSlug.compare(a, b).should.equal +1
ItemSlug.compare(b, a).should.equal -1
it 'sorts by mod when both are qualified', ->
a = new ItemSlug 'alpha', 'bravo'
b = new ItemSlug 'charlie', 'delta'
ItemSlug.compare(a, b).should.equal -1
ItemSlug.compare(b, a).should.equal +1
it 'sorts by item when both are qualified in the same mod', -> it 'sorts by item when both are qualified in the same mod', ->
a = new ItemSlug 'alpha', 'bravo' a = new ItemSlug 'alpha', 'bravo'
b = new ItemSlug 'charlie', 'bravo' b = new ItemSlug 'charlie', 'bravo'
@@ -184,7 +184,7 @@ describe 'mod_version_parser_v1.coffee', ->
it 'registers slugs for each output name', -> it 'registers slugs for each output name', ->
modVersion = parser.parse baseText + 'extras:Delta, Echo' modVersion = parser.parse baseText + 'extras:Delta, Echo'
(s.qualified for s in modVersion._slugs).should.eql [ (s.qualified for s in modVersion._slugs).should.eql [
'test__bravo', 'test__delta', 'charlie', 'echo' 'test__bravo', 'charlie', 'test__delta', 'echo'
] ]
it 'does not allow a duplicate "extras" declaration', -> it 'does not allow a duplicate "extras" declaration', ->