diff --git a/design/pages.graffle b/design/pages.graffle index 36ad1149e..0772ad630 100644 Binary files a/design/pages.graffle and b/design/pages.graffle differ diff --git a/scripts/server b/scripts/server index 2b5678106..c3dcfbd12 100755 --- a/scripts/server +++ b/scripts/server @@ -31,6 +31,7 @@ serveIndex = (request, response)-> app.get '*/$', serveIndex app.get '/craft/*', serveIndex +app.get '/login', serveIndex app.get '*', (request, response)-> extension = path.extname(request.path) @@ -49,5 +50,5 @@ app.get '*', (request, response)-> response.set 'Content-Type', contentType response.sendFile request.path, root:ROOT -server = app.listen 8000, -> +server = app.listen 8000, '127.0.0.1', -> console.log "listening at: #{server.address().address}:#{server.address().port}" diff --git a/src/coffee/constants.coffee b/src/coffee/constants.coffee index 4341b9a53..9b8fdb404 100644 --- a/src/coffee/constants.coffee +++ b/src/coffee/constants.coffee @@ -61,6 +61,20 @@ Event.transitionEnd = (-> exports.Key = Key = {} Key.Return = 13 +exports.Login = Login = {} +Login.authorizeUrl = _.template "https://github.com/login/oauth/authorize" + + "?client_id=<%= clientId %>&scope=public_repo&state=<%= state %>" +Login.clientIds = + 'local': 'a2a1c5f1bb2d7bd14ebb' + 'staging': '26e1e9a4a702cabe334f' + 'production': 'ea419abd2ab96c708815' + +exports.ModelState = ModelState = {} +ModelState.unloaded = 'unloaded' +ModelState.loading = 'loading' +ModelState.loaded = 'loaded' +ModelState.failed = 'failed' + exports.Opacity = Opacity = {} Opacity.hidden = 1e-6 Opacity.shown = 1 @@ -69,12 +83,6 @@ exports.ProductionEnvs = [ 'staging', 'production' ] exports.RequiredMods = [ 'minecraft' ] -exports.ModelState = ModelState = {} -ModelState.unloaded = 'unloaded' -ModelState.loading = 'loading' -ModelState.loaded = 'loaded' -ModelState.failed = 'failed' - exports.Text = Text = {} Text.title = 'The Ultimate Minecraft Crafting Guide' @@ -83,10 +91,12 @@ Url.crafting = _.template "/craft/<%= inventoryText %>" Url.item = _.template "/browse/<%= modSlug %>/<%= itemSlug %>/" Url.itemData = _.template "/browse/<%= modSlug %>/<%= itemSlug %>/item.cg" Url.itemIcon = _.template "/browse/<%= modSlug %>/<%= itemSlug %>/icon.png" +Url.login = _.template "/login" Url.mod = _.template "/browse/<%= modSlug %>/" Url.modData = _.template "/data/<%= modSlug %>/mod.cg" Url.modIcon = _.template "/browse/<%= modSlug %>/icon.png" Url.modVersionData = _.template "/data/<%= modSlug %>/<%= modVersion %>/mod-version.cg" +Url.root = _.template "/" Url.tutorial = _.template "/browse/<%= modSlug %>/tutorials/<%= tutorialSlug %>/" Url.tutorialData = _.template "/data/<%= modSlug %>/tutorials/<%= tutorialSlug %>.cg" Url.tutorialIcon = _.template "/browse/<%= modSlug %>/tutorials/<%= tutorialSlug %>/icon.png" diff --git a/src/coffee/controllers/base_controller.coffee b/src/coffee/controllers/base_controller.coffee index e3a50ffb9..d23886f43 100644 --- a/src/coffee/controllers/base_controller.coffee +++ b/src/coffee/controllers/base_controller.coffee @@ -14,14 +14,12 @@ module.exports = class BaseController extends Backbone.View constructor: (options={})-> @tryRefresh = _.debounce @_tryRefresh, 100 - Object.defineProperty this, 'model', get:@getModel, set:@setModel @_model = null @_rendered = false @_parent = options.parent @_children = [] - Object.defineProperty this, 'rendered', get:-> return @_rendered @_loadTemplate options.templateName super options @@ -122,6 +120,21 @@ module.exports = class BaseController extends Backbone.View @_model = newModel @tryRefresh() + isRendered: -> + return @_rendered + + getUser: -> + return @_user + + setUser: (user)-> + @_user = user + @tryRefresh() + + Object.defineProperties @prototype, + model: {get:@prototype.getModel, set:@prototype.setModel} + rendered: {get:@prototype.isRendered } + user: {get:@prototype.user, set:@prototype.setUser} + # Backbone.View Overrides ###################################################################### events: -> diff --git a/src/coffee/controllers/header_controller.coffee b/src/coffee/controllers/header_controller.coffee index bf2860878..5d8db72f2 100644 --- a/src/coffee/controllers/header_controller.coffee +++ b/src/coffee/controllers/header_controller.coffee @@ -7,17 +7,36 @@ All rights reserved. BaseController = require './base_controller' {Duration} = require '../constants' +{Event} = require '../constants' {ProductionEnvs} = require '../constants' +{Url} = require '../constants' ######################################################################################################################## module.exports = class HeaderController extends BaseController constructor: (options={})-> + if not options.client? then throw new Error 'options.client is required' super options + @client = options.client + @_user = options.user + # Event Methods ################################################################################ + onLoginLinkClicked: (event)-> + event.preventDefault() + + if @user? + @client.logout() + .then -> + router.user = null + .catch (error)-> + logger.error -> "Failed to log out: #{error}" + .done() + else + router.navigate Url.login(), trigger:true + onLogoClicked: -> router.navigate '/', trigger:true return false @@ -28,6 +47,18 @@ module.exports = class HeaderController extends BaseController router.navigate $(event.currentTarget).attr('href'), trigger:true return false + # Property Methods ############################################################################# + + getUser: -> + return @_user + + setUser: (user)-> + @_user = user + @tryRefresh() + + Object.defineProperties @prototype, + user: {get:@prototype.getUser, set:@prototype.setUser} + # BaseController Overrides ##################################################################### render: -> @@ -36,6 +67,8 @@ module.exports = class HeaderController extends BaseController @_rendered = true @$navLinks = ($(el) for el in @$('.navBar a')) + @$loginName = @$('.login .name') + @$loginLink = @$('.login a') zIndex = @$navLinks.length + 100 for $navLink in @$navLinks @@ -46,6 +79,13 @@ module.exports = class HeaderController extends BaseController $('body').append "" refresh: -> + if @user? + @$loginName.html "Hello #{router.user.login}!" + @$loginLink.html "Logout" + else + @$loginName.html '' + @$loginLink.html 'Login' + for $navLink in @$navLinks linkPage = $navLink.data('page') if @model is linkPage @@ -64,3 +104,4 @@ module.exports = class HeaderController extends BaseController return _.extend super, 'click a.logo': 'onLogoClicked' 'click .navBar a': 'onNavItemClicked' + 'click .login a': 'onLoginLinkClicked' diff --git a/src/coffee/controllers/login_page_controller.coffee b/src/coffee/controllers/login_page_controller.coffee new file mode 100644 index 000000000..a62f7daf1 --- /dev/null +++ b/src/coffee/controllers/login_page_controller.coffee @@ -0,0 +1,130 @@ +### +Crafting Guide - login_page_controller.coffee + +Copyright (c) 2015 by Redwood Labs +All rights reserved. +### + +PageController = require './page_controller' +User = require '../models/user' +{CraftingGuideClient} = require 'crafting-guide-common' +{Event} = require '../constants' +{Login} = require '../constants' +{Url} = require '../constants' + +######################################################################################################################## + +module.exports = class LoginPageController extends PageController + + @State: State = + ReadyToLogin: 'ready-to-login' + ServerDown: 'server-down' + InvalidCallback: 'invalid-callback' + FetchingToken: 'fetching-token' + LoggedIn: 'logged-in' + + constructor: (options={})-> + options.templateName = 'login_page' + + @client = options.client + @params = options.params + @storage = options.storage + + @_accessTokenLookupFailed = false + + @client.on Event.change + ':status', => @tryRefresh() + + super options + + # Event Methods ################################################################################ + + onExpandReadMore: (event)-> + event.preventDefault() + @$readMoreLink.addClass 'hidden' + @$readMoreContent.removeClass 'closed' + + onLoginButtonClicked: (event)-> + event.preventDefault() + @_redirectToGitHub() + + # Property Methods ############################################################################# + + getLoginSecurityToken: -> + @storage.load 'loginSecurityToken' + + setLoginSecurityToken: (value)-> + @storage.store 'loginSecurityToken', value + + Object.defineProperties @prototype, + loginSecurityToken: {get:@prototype.getLoginSecurityToken, set:@prototype.setLoginSecurityToken} + serverStatus: {get:@prototype.getServerStatus, set:@prototype.setServerStatus} + + # PageController Overrides ##################################################################### + + getTitle: -> + return "Login" + + # BaseController Methods ####################################################################### + + onDidRender: -> + @$readMoreLink = @$('a.read-more') + @$readMoreContent = @$('.read-more-content') + + if (not @user?) and (@params?.state is @loginSecurityToken) + @client.completeGitHubLogin code:@params.code + .then (response)-> + attributes = response.json.data.user + if attributes? + router.user = new User attributes + router.navigate Url.root(), trigger:true + .catch (error)-> + logger.error "Failed to get access token: #{error}" + @_accessTokenLookupFailed = true + @refresh() + .done() + super + + refresh: -> + state = @_computeState() + @$('.two-thirds-content').addClass 'hidden' + @$(".two-thirds-content.#{state}").removeClass 'hidden' + + super + + # Backbone.View Overrides ###################################################################### + + events: -> + return _.extend super, + 'click a.read-more': 'onExpandReadMore' + 'click button': 'onLoginButtonClicked' + + # Private Methods ############################################################################## + + _computeState: -> + if @user? then return State.LoggedIn + + if @client.status is CraftingGuideClient.Status.Down then return State.ServerDown + + if @_accessTokenLookupFailed then return State.InvalidCallback + + if @params?.state? + if @params.state is @loginSecurityToken + return State.FetchingToken + else + return State.InvalidCallback + + return State.ReadyToLogin + + _compeleteLogin: (code)-> + @client.completeGitHubLogin code:code + .then (response)=> + router.user = new User response.json.user + .catch (error)-> + @_accessTokenLookupFailed = true + @refresh + .done() + + _redirectToGitHub: -> + @loginSecurityToken = _.uuid() + clientId = Login.clientIds[global.env] + window.location.href = Login.authorizeUrl clientId:clientId, state:@loginSecurityToken diff --git a/src/coffee/crafting_guide_router.coffee b/src/coffee/crafting_guide_router.coffee index 01dbccdcd..9c61c4821 100644 --- a/src/coffee/crafting_guide_router.coffee +++ b/src/coffee/crafting_guide_router.coffee @@ -13,12 +13,14 @@ HomePageController = require './controllers/home_page_controller' ImageLoader = require './controllers/image_loader' ItemPageController = require './controllers/item_page_controller' ItemSlug = require './models/item_slug' +LoginPageController = require './controllers/login_page_controller' Mod = require './models/mod' ModPack = require './models/mod_pack' ModPageController = require './controllers/mod_page_controller' TutorialPageController = require './controllers/tutorial_page_controller' Storage = require './models/storage' UrlParams = require './url_params' +User = require './models/user' {ProductionEnvs} = require './constants' {DefaultMods} = require './constants' {Duration} = require './constants' @@ -34,15 +36,20 @@ module.exports = class CraftingGuideRouter extends Backbone.Router @_page = null @_pageControllers = {} @_lastReported = null + @_user = null super options - @imageLoader = new ImageLoader defaultUrl:'/images/unknown.png' - @modPack = new ModPack - @storage = new Storage storage:window.localStorage - @_defaultOptions = imageLoader:@imageLoader, modPack:@modPack, storage:@storage + @client = options.client + @imageLoader = new ImageLoader defaultUrl:'/images/unknown.png' + @modPack = new ModPack + @storage = new Storage storage:window.localStorage - @headerController = new HeaderController el:'.view__header' + @_defaultOptions = client:@client, imageLoader:@imageLoader, modPack:@modPack, storage:@storage, user:@_user + @on Event.change + ':user', => @_defaultOptions.user = @_user + + @headerController = new HeaderController _.extend {el:'.view__header'}, @_defaultOptions @headerController.render() + @on Event.change + ':user', => @headerController.user = @user # Public Methods ############################################################################### @@ -58,6 +65,38 @@ module.exports = class CraftingGuideRouter extends Backbone.Router @modPack.addMod mod + loadCurrentUser: -> + @client.fetchCurrentUser() + .then (response)=> + userData = response.json?.data?.user + @user = new User userData if userData? + .catch (error)=> + if error.response.statusCode isnt 401 + logger.error "Failed to get current user: #{error}" + else + logger.info "User is not logged in" + .done() + + # Property Methods ############################################################################# + + getUser: -> + return @_user + + setUser: (newUser)-> + oldUser = @_user + return if newUser is oldUser + + @_user = newUser + logger.info -> "User changed to: #{newUser}" + + @trigger Event.change + ':user', this, oldUser, newUser + @trigger Event.change, this + + if @_controller? then @_controller.user = newUser + + Object.defineProperties @prototype, + user: {get:@prototype.getUser, set:@prototype.setUser} + # Backbone.Router Overrides #################################################################### navigate: -> @@ -73,6 +112,7 @@ module.exports = class CraftingGuideRouter extends Backbone.Router 'configure(/)': 'route__configure' 'craft(/)': 'route__craft' 'craft/:text': 'route__craft' + 'login(/)': 'route__login' 'item/:itemSlug': 'deprecated__item' 'crafting/(:text)': 'deprecated__crafting' @@ -114,6 +154,11 @@ module.exports = class CraftingGuideRouter extends Backbone.Router controller.model.params = inventoryText:text @_setPage 'craft', controller + route__login: -> + params = new UrlParams code:{type:'string'}, state:{type:'string'} + controller = new LoginPageController _.extend {params:params}, @_defaultOptions + @_setPage 'login', controller + # Deprecated Route Methods ##################################################################### deprecated__crafting: (text)-> @@ -175,6 +220,7 @@ module.exports = class CraftingGuideRouter extends Backbone.Router window.scrollTo 0, 0 + controller.user = @user controller.onWillShow() controller.$el = $pageContent controller.render() diff --git a/src/coffee/main.coffee b/src/coffee/main.coffee index 1a8fa2582..e306d950c 100644 --- a/src/coffee/main.coffee +++ b/src/coffee/main.coffee @@ -8,10 +8,11 @@ All rights reserved. require './underscore_mixins' require './polyfill' -CraftingGuideRouter = require './crafting_guide_router' -FeedbackController = require './controllers/feedback_controller' -views = require './views' -{Logger} = require 'crafting-guide-common' +CraftingGuideRouter = require './crafting_guide_router' +FeedbackController = require './controllers/feedback_controller' +views = require './views' +{CraftingGuideClient} = require 'crafting-guide-common' +{Logger} = require 'crafting-guide-common' ######################################################################################################################## @@ -22,17 +23,29 @@ if typeof(global) is 'undefined' global.logger = new Logger switch window.location.hostname - when 'localhost' - global.env = 'development' + when 'local.crafting-guide.com' + global.env = 'local' logger.level = Logger.DEBUG + apiBaseUrl = 'http://local.crafting-guide.com:8001' when 'new.crafting-guide.com' - global.env = 'staging' + global.env = 'staging' logger.level = Logger.VERBOSE + apiBaseUrl = 'https://crafting-guide-production.herokuapp.com' when 'crafting-guide.com' - global.env = 'production' + global.env = 'production' logger.level = Logger.INFO + apiBaseUrl = 'https://crafting-guide-production.herokuapp.com' + else + throw new Error "cannot determine the environment of: #{window.location.hostname}" -global.router = new CraftingGuideRouter +client = _.extend new CraftingGuideClient(baseUrl:apiBaseUrl), Backbone.Events +client.onStatusChanged = (c, oldStatus, newStatus)-> + logger.info "Crafting Guide server status changed from #{oldStatus} to #{newStatus}" + client.trigger 'change:status', client, oldStatus, newStatus + client.trigger 'change', client +client.checkStatus() + +global.router = new CraftingGuideRouter client:client global.util = require 'util' global.views = views global.markdown = global.markdown.markdown @@ -40,6 +53,7 @@ global.markdown = global.markdown.markdown global.feedbackController = new FeedbackController el:'.view__feedback' feedbackController.render() +global.router.loadCurrentUser() global.router.loadDefaultModPack() logger.info -> "CraftingGuide is ready" diff --git a/src/coffee/models/user.coffee b/src/coffee/models/user.coffee new file mode 100644 index 000000000..0d085bc2e --- /dev/null +++ b/src/coffee/models/user.coffee @@ -0,0 +1,33 @@ +### +Crafting Guide - user.coffee + +Copyright (c) 2015 by Redwood Labs +All rights reserved. +### + +BaseModel = require './base_model' +{Url} = require '../constants' + +######################################################################################################################## + +module.exports = class GitHubUser extends BaseModel + + constructor: (attributes={}, options={})-> + attributes.avatarUrl ?= null + attributes.email ?= null + attributes.login ?= null + attributes.name ?= null + super attributes, options + + # Property Methods ############################################################################# + + isAuthenticated: -> + return @login? + + Object.defineProperties @prototype, + authenticated: {get:@prototype.isAuthenticated} + + # Object Overrides ############################################################################# + + toString: -> + return "#{@name} (#{@login})" diff --git a/src/coffee/underscore_mixins.coffee b/src/coffee/underscore_mixins.coffee index ac789d648..99fbfeb07 100644 --- a/src/coffee/underscore_mixins.coffee +++ b/src/coffee/underscore_mixins.coffee @@ -31,3 +31,5 @@ _.mixin parts = [ null, parts[0] ] return parts + +_.mixin require('crafting-guide-common').stringMixins diff --git a/src/jade/includes/_header.jade b/src/jade/includes/_header.jade index 676fcb48e..1ac8ce3dc 100644 --- a/src/jade/includes/_header.jade +++ b/src/jade/includes/_header.jade @@ -12,6 +12,9 @@ .addthis_sharing_toolbox .divider.bottom + .login + span.name + a(href="/login") Login .navBar a(data-page="home", href="/") diff --git a/src/jade/templates/login_page.jade b/src/jade/templates/login_page.jade new file mode 100644 index 000000000..addd6bd1e --- /dev/null +++ b/src/jade/templates/login_page.jade @@ -0,0 +1,63 @@ +//- +//- Crafting Guide - login_page.jade +//- +//- Copyright (c) 2015 by Redwood Labs +//- All rights reserved. +//- + +.view__login_page + .two-thirds-content.hideable.ready-to-login + img.float-right(src="/images/github-login-large.png") + h1 Login with GitHub + p. + Use your GitHub credentials to login and make changes on Crafting Guide. No GitHub account? Don't worry, you + can get one for free. read more... + .read-more-content.slideable.closed + p. + All changes you make on the site get submitted directly to our GitHub repo, and then published through + the same automated deployment process as changes made by anyone else. That way, anyone can contribute + easily: whether developer or end user. + p. + To log in, we redirect you to GitHub's site, and once you're signed in, they'll send you back here. We + never see your credentials, and only get the minimum set of permissions necessary to submit changes to + the Crafting Guide repo on your behalf. + p. + GitHub offers free accounts, so even if you don't already have one, you've got nothing to lose! + + .actions + button.huge Login with GitHub + + .clear + + .two-thirds-content.hideable.hidden.server-down + h1 Sorry! + p. + Our interactive editing service is down right now, but you can always submit a pull request over at + the Crafting Guide GitHub Repository. We'll have + the editing service back up soon! + + .two-thirds-content.hideable.hidden.invalid-callback + img.float-right(src="/images/github-login-large.png") + h1 Oops! + p. + Sorry about that, but it looks like something went wrong with your GitHub login. Click the button below to + try again, or use the Feedback button on the left to let us know, and we'll try to help. + + .actions + button.huge Login with GitHub + + .clear + + .two-thirds-content.hideable.hidden.fetching-token + img.float-right(src="/images/github-login-large.png") + h1 Completing log in... + p. + Hang on just a second while we finish logging you in... + + .clear + + .two-thirds-content.hideable.hidden.logged-in + img.float-right(src="/images/github-login-large.png") + h1 All set! + + .clear diff --git a/src/scss/classes.scss b/src/scss/classes.scss index 74d7a4f7f..e651ea904 100644 --- a/src/scss/classes.scss +++ b/src/scss/classes.scss @@ -19,6 +19,10 @@ All rights reserved. text-align: center; } +.clear { + clear: both; +} + .content { background: $color-content; box-shadow: 10px 10px 20px #666; @@ -32,6 +36,11 @@ All rights reserved. .error-new { background: $color-error-new !important; } +.float-right { + float: right; + margin-left: 3em; +} + .hideable { opacity: 1; transition: opacity $animate-normal; @@ -111,6 +120,21 @@ All rights reserved. } } +.slideable { + max-height: 500px; + overflow: hidden; + transition: max-height $animate-normal; + + &.closed { + max-height: 0; + } +} + +.two-thirds-content { + position: relative; width: 66%; + margin: 5em 16.5%; +} + .videos { .panel { text-align: center; diff --git a/src/scss/main.scss b/src/scss/main.scss index 8a09b65f9..36bd768a0 100644 --- a/src/scss/main.scss +++ b/src/scss/main.scss @@ -77,6 +77,7 @@ $color-footer-text: #FFFFFF; $color-link: #0000FF; $color-link-subtle: #545A99; $color-text: #000000; +$color-text-light: #FFFFFF; // Z-Index Layers ////////////////////////////////////////////////////////////////////////////////// diff --git a/src/scss/tags.scss b/src/scss/tags.scss index 93fbc2efe..0de72e9bb 100644 --- a/src/scss/tags.scss +++ b/src/scss/tags.scss @@ -29,6 +29,11 @@ button { margin: 2em 0; padding: 0.5em 0.5em; } + + &.huge { + font-size: $font-size-large; + padding: 0.5em 3em; + } } h1 { diff --git a/src/scss/templates/index.scss b/src/scss/templates/index.scss index 19bc6da1e..b009b69a3 100644 --- a/src/scss/templates/index.scss +++ b/src/scss/templates/index.scss @@ -17,6 +17,7 @@ All rights reserved. @import 'inventory_table'; @import 'item'; @import 'item_page'; +@import 'login_page'; @import 'minimal_recipe'; @import 'mod'; @import 'mod_pack'; diff --git a/src/scss/templates/login_page.scss b/src/scss/templates/login_page.scss new file mode 100644 index 000000000..9ce13fcd3 --- /dev/null +++ b/src/scss/templates/login_page.scss @@ -0,0 +1,33 @@ +/* +Crafting Guide - login_page.scss + +Copyright (C) 2015 by Redwood Labs +All rights reserved. +*/ + +.view__login_page { + + button { + outline: none; + } + + h1 { + font-family: $font-family-header; + font-size: $font-size-x-large; + padding-top: 2em; + } + + p { + margin-bottom: 1em; + } + + .actions { + margin-top: 4em; + width: 100%; + text-align: center; + } + + .read-more { + font-size: 75%; + } +} diff --git a/src/scss/views.scss b/src/scss/views.scss index f5e157e16..4af1215e7 100644 --- a/src/scss/views.scss +++ b/src/scss/views.scss @@ -55,6 +55,25 @@ All rights reserved. .divider { background: $color-brick-dark url('/images/stone_brick.png'); position: absolute; bottom: 5em; height: 3.2em; width: 100%; + + .login { + position: absolute; top: 50%; right: 2em; + @include transform(translateY(-50%)); + + a { + color: $color-text-light; + font-family: $font-family-normal; + font-size: $font-size-normal; + font-weight: bold; + } + + .name { + color: $color-text-light; + font-family: $font-family-normal; + font-size: $font-size-normal; + margin-right: 2em; + } + } } .navBar { diff --git a/static/images/github-login-large.png b/static/images/github-login-large.png new file mode 100644 index 000000000..c7b76b960 Binary files /dev/null and b/static/images/github-login-large.png differ