From c4779468bd7abd4bb86a1f74958635596d061463 Mon Sep 17 00:00:00 2001 From: Josh Callebaut <josh.callebaut@gmail.com> Date: Tue, 10 Nov 2015 13:46:48 -0800 Subject: [PATCH 1/6] Adds Josh C's image and updates the about page. --- app/assets/images/pages/about/josh_c_small.png | Bin 0 -> 11507 bytes app/templates/about.jade | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 app/assets/images/pages/about/josh_c_small.png diff --git a/app/assets/images/pages/about/josh_c_small.png b/app/assets/images/pages/about/josh_c_small.png new file mode 100644 index 0000000000000000000000000000000000000000..6a04f9e2eb9aa491168489e3975ec9467c22fbe6 GIT binary patch literal 11507 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 Z3;^2d8YFF{93lV!002ovPDHLkV1g6xVYC1M literal 0 HcmV?d00001 diff --git a/app/templates/about.jade b/app/templates/about.jade index 7a086a093..bf836c1b0 100644 --- a/app/templates/about.jade +++ b/app/templates/about.jade @@ -184,8 +184,8 @@ block content | Compiler Engineer p(data-i18n="about.rob_blurb") | Codes things and stuff. - - img(src="/images/pages/about/placeholder.png").img-thumbnail + + img(src="/images/pages/about/josh_c_small.png").img-thumbnail .team_bio h4.team_name | Josh Callebaut From e33323e7eb8a36739f637e89e159bc2b40da81a0 Mon Sep 17 00:00:00 2001 From: Matt Lott <mattlott@live.com> Date: Tue, 10 Nov 2015 11:20:35 -0800 Subject: [PATCH 2/6] Add line charts to admin analytics dashboard https://app.asana.com/0/54276215890539/64369256136957 --- app/core/d3_utils.coffee | 127 +++++++++ app/styles/admin/analytics.sass | 12 + app/templates/admin/analytics.jade | 19 +- app/views/admin/AnalyticsView.coffee | 381 +++++++++++++++++++++------ 4 files changed, 461 insertions(+), 78 deletions(-) create mode 100644 app/core/d3_utils.coffee diff --git a/app/core/d3_utils.coffee b/app/core/d3_utils.coffee new file mode 100644 index 000000000..843d6da81 --- /dev/null +++ b/app/core/d3_utils.coffee @@ -0,0 +1,127 @@ +# Caller needs require 'vendor/d3' + +module.exports.createContiguousDays = (timeframeDays) -> + # Return list of last 'timeframeDays' contiguous days in yyyy-mm-dd format + days = [] + currentDate = new Date() + currentDate.setUTCDate(currentDate.getUTCDate() - timeframeDays) + for i in [0..timeframeDays] + currentDay = currentDate.toISOString().substr(0, 10) + days.push(currentDay) + currentDate.setUTCDate(currentDate.getUTCDate() + 1) + days + +module.exports.createLineChart = (containerSelector, chartLines) -> + # Creates a line chart within 'containerSelector' based on chartLines + return unless chartLines?.length > 0 and containerSelector + + margin = 20 + keyHeight = 20 + xAxisHeight = 20 + yAxisWidth = 40 + containerWidth = $(containerSelector).width() + containerHeight = $(containerSelector).height() + + yScaleCount = 0 + yScaleCount++ for line in chartLines when line.showYScale + svg = d3.select(containerSelector).append("svg") + .attr("width", containerWidth) + .attr("height", containerHeight) + width = containerWidth - margin * 2 - yAxisWidth * yScaleCount + height = containerHeight - margin * 2 - xAxisHeight - keyHeight * chartLines.length + currentLine = 0 + currentYScale = 0 + + # Horizontal guidelines + marks = (Math.round(i * height / 5) for i in [1..5]) + yRange = d3.scale.linear().range([height, 0]).domain([0, height]) + svg.selectAll(".line") + .data(marks) + .enter() + .append("line") + .attr("x1", margin + yAxisWidth * yScaleCount) + .attr("y1", (d) -> margin + yRange(d)) + .attr("x2", margin + yAxisWidth * yScaleCount + width) + .attr("y2", (d) -> margin + yRange(d)) + .attr("stroke", 'gray') + .style("opacity", "0.3") + + for line in chartLines + # continue unless line.enabled + xRange = d3.scale.linear().range([0, width]).domain([d3.min(line.points, (d) -> d.x), d3.max(line.points, (d) -> d.x)]) + yRange = d3.scale.linear().range([height, 0]).domain([line.min, line.max]) + + # x-Axis + if currentLine is 0 + startDay = new Date(line.points[0].day) + endDay = new Date(line.points[line.points.length - 1].day) + xAxisRange = d3.time.scale() + .domain([startDay, endDay]) + .range([0, width]) + xAxis = d3.svg.axis() + .scale(xAxisRange) + svg.append("g") + .attr("class", "x axis") + .call(xAxis) + .selectAll("text") + .attr("dy", ".35em") + .attr("transform", "translate(" + (margin + yAxisWidth) + "," + (height + margin) + ")") + .style("text-anchor", "start") + + if line.showYScale + # y-Axis + yAxisRange = d3.scale.linear().range([height, 0]).domain([line.min, line.max]) + yAxis = d3.svg.axis() + .scale(yRange) + .orient("left") + svg.append("g") + .attr("class", "y axis") + .attr("transform", "translate(" + (margin + yAxisWidth * currentYScale) + "," + margin + ")") + .style("color", line.lineColor) + .call(yAxis) + .selectAll("text") + .attr("y", 0) + .attr("x", 0) + .attr("fill", line.lineColor) + .style("text-anchor", "start") + currentYScale++ + + # Key + svg.append("line") + .attr("x1", margin) + .attr("y1", margin + height + xAxisHeight + keyHeight * currentLine + keyHeight / 2) + .attr("x2", margin + 40) + .attr("y2", margin + height + xAxisHeight + keyHeight * currentLine + keyHeight / 2) + .attr("stroke", line.lineColor) + .attr("class", "key-line") + svg.append("text") + .attr("x", margin + 40 + 10) + .attr("y", margin + height + xAxisHeight + keyHeight * currentLine + (keyHeight + 10) / 2) + .attr("fill", line.lineColor) + .attr("class", "key-text") + .text(line.description) + + # Path and points + svg.selectAll(".circle") + .data(line.points) + .enter() + .append("circle") + .attr("transform", "translate(" + (margin + yAxisWidth * yScaleCount) + "," + margin + ")") + .attr("cx", (d) -> xRange(d.x)) + .attr("cy", (d) -> yRange(d.y)) + .attr("r", 2) + .attr("fill", line.lineColor) + .attr("stroke-width", 1) + .attr("class", "graph-point") + .attr("data-pointid", (d) -> "#{line.lineID}#{d.x}") + d3line = d3.svg.line() + .x((d) -> xRange(d.x)) + .y((d) -> yRange(d.y)) + .interpolate("linear") + svg.append("path") + .attr("d", d3line(line.points)) + .attr("transform", "translate(" + (margin + yAxisWidth * yScaleCount) + "," + margin + ")") + .style("stroke-width", line.strokeWidth) + .style("stroke", line.lineColor) + .style("fill", "none") + currentLine++ diff --git a/app/styles/admin/analytics.sass b/app/styles/admin/analytics.sass index 04f72ee5e..14df067d2 100644 --- a/app/styles/admin/analytics.sass +++ b/app/styles/admin/analytics.sass @@ -14,3 +14,15 @@ font-size: 70pt .description font-size: 8pt + + .line-chart-container + height: 500px + width: 100% + .x.axis + font-size: 9pt + path + display: none + .y.axis + font-size: 9pt + path + display: none diff --git a/app/templates/admin/analytics.jade b/app/templates/admin/analytics.jade index 6c2fc8f4a..87de02c12 100644 --- a/app/templates/admin/analytics.jade +++ b/app/templates/admin/analytics.jade @@ -1,7 +1,9 @@ extends /templates/base block content - + + //- NOTE: do not localize / i18n + if me.isAdmin() .container-fluid .row @@ -18,6 +20,21 @@ block content div.description 30-day Active Users div.count= activeUsers[0].monthlyCount + h3 KPI 60 days + .kpi-recent-chart.line-chart-container + + h3 KPI 300 days + .kpi-chart.line-chart-container + + h3 Active Classes 90 days + .active-classes-chart.line-chart-container + + h3 Recurring Revenue 90 days + .recurring-revenue-chart.line-chart-container + + h3 Active Users 90 days + .active-users-chart.line-chart-container + h1 Active Classes table.table.table-striped.table-condensed tr diff --git a/app/views/admin/AnalyticsView.coffee b/app/views/admin/AnalyticsView.coffee index 390c39673..66d88a293 100644 --- a/app/views/admin/AnalyticsView.coffee +++ b/app/views/admin/AnalyticsView.coffee @@ -1,3 +1,5 @@ +require 'vendor/d3' +d3Utils = require 'core/d3_utils' RootView = require 'views/core/RootView' template = require 'templates/admin/analytics' utils = require 'core/utils' @@ -5,86 +7,11 @@ utils = require 'core/utils' module.exports = class AnalyticsView extends RootView id: 'admin-analytics-view' template: template + lineColors: ['red', 'blue', 'green', 'purple', 'goldenrod', 'brown', 'darkcyan'] constructor: (options) -> super options - - @supermodel.addRequestResource('active_classes', { - url: '/db/analytics_perday/-/active_classes' - method: 'POST' - success: (data) => - @activeClassGroups = {} - dayEventsMap = {} - for activeClass in data - dayEventsMap[activeClass.day] ?= {} - dayEventsMap[activeClass.day]['Total'] = 0 - for event, val of activeClass.classes - @activeClassGroups[event] = true - dayEventsMap[activeClass.day][event] = val - dayEventsMap[activeClass.day]['Total'] += val - @activeClassGroups = Object.keys(@activeClassGroups) - @activeClassGroups.push 'Total' - for day of dayEventsMap - for event in @activeClassGroups - dayEventsMap[day][event] ?= 0 - @activeClasses = [] - for day of dayEventsMap - data = day: day, groups: [] - for group in @activeClassGroups - data.groups.push(dayEventsMap[day][group] ? 0) - @activeClasses.push data - @activeClasses.sort (a, b) -> b.day.localeCompare(a.day) - @render?() - }, 0).load() - - @supermodel.addRequestResource('active_users', { - url: '/db/analytics_perday/-/active_users' - method: 'POST' - success: (data) => - @activeUsers = data - @activeUsers.sort (a, b) -> b.day.localeCompare(a.day) - @render?() - }, 0).load() - - @supermodel.addRequestResource('recurring_revenue', { - url: '/db/analytics_perday/-/recurring_revenue' - method: 'POST' - success: (data) => - @revenueGroups = {} - dayGroupCountMap = {} - for dailyRevenue in data - dayGroupCountMap[dailyRevenue.day] ?= {} - dayGroupCountMap[dailyRevenue.day]['Daily'] = 0 - for group, val of dailyRevenue.groups - @revenueGroups[group] = true - dayGroupCountMap[dailyRevenue.day][group] = val - dayGroupCountMap[dailyRevenue.day]['Daily'] += val - @revenueGroups = Object.keys(@revenueGroups) - @revenueGroups.push 'Daily' - @revenueGroups.push 'Monthly' - for day of dayGroupCountMap - for group in @revenueGroups - dayGroupCountMap[day][group] ?= 0 - @revenue = [] - for day of dayGroupCountMap - data = day: day, groups: [] - for group in @revenueGroups - data.groups.push(dayGroupCountMap[day][group] ? 0) - @revenue.push data - @revenue.sort (a, b) -> b.day.localeCompare(a.day) - monthlyValues = [] - - return unless @revenue.length > 0 - - for i in [@revenue.length-1..0] - dailyTotal = @revenue[i].groups[@revenue[i].groups.length - 2] - monthlyValues.push(dailyTotal) - monthlyValues.shift() if monthlyValues.length > 30 - if monthlyValues.length is 30 - monthlyIndex = @revenue[i].groups.length - 1 - @revenue[i].groups[monthlyIndex] = _.reduce(monthlyValues, (s, num) -> s + num) - @render?() - }, 0).load() + @loadData() getRenderData: -> context = super() @@ -94,3 +21,303 @@ module.exports = class AnalyticsView extends RootView context.revenue = @revenue ? [] context.revenueGroups = @revenueGroups ? {} context + + afterRender: -> + super() + @createLineCharts() + + loadData: -> + @supermodel.addRequestResource('active_classes', { + url: '/db/analytics_perday/-/active_classes' + method: 'POST' + success: (data) => + # Organize data by day, then group + groupMap = {} + dayGroupMap = {} + for activeClass in data + dayGroupMap[activeClass.day] ?= {} + dayGroupMap[activeClass.day]['Total'] = 0 + for group, val of activeClass.classes + groupMap[group] = true + dayGroupMap[activeClass.day][group] = val + dayGroupMap[activeClass.day]['Total'] += val + @activeClassGroups = Object.keys(groupMap) + @activeClassGroups.push 'Total' + # Build list of active classes, where each entry is a day of individual group values + @activeClasses = [] + for day of dayGroupMap + dashedDay = "#{day.substring(0, 4)}-#{day.substring(4, 6)}-#{day.substring(6, 8)}" + data = day: dashedDay, groups: [] + for group in @activeClassGroups + data.groups.push(dayGroupMap[day][group] ? 0) + @activeClasses.push data + @activeClasses.sort (a, b) -> b.day.localeCompare(a.day) + + @updateAllKPIChartData() + @updateActiveClassesChartData() + @render?() + }, 0).load() + + @supermodel.addRequestResource('active_users', { + url: '/db/analytics_perday/-/active_users' + method: 'POST' + success: (data) => + @activeUsers = data.map (a) -> + a.day = "#{a.day.substring(0, 4)}-#{a.day.substring(4, 6)}-#{a.day.substring(6, 8)}" + a + @activeUsers.sort (a, b) -> b.day.localeCompare(a.day) + + @updateAllKPIChartData() + @updateActiveUsersChartData() + @render?() + }, 0).load() + + @supermodel.addRequestResource('recurring_revenue', { + url: '/db/analytics_perday/-/recurring_revenue' + method: 'POST' + success: (data) => + # Organize data by day, then group + groupMap = {} + dayGroupCountMap = {} + for dailyRevenue in data + dayGroupCountMap[dailyRevenue.day] ?= {} + dayGroupCountMap[dailyRevenue.day]['Daily'] = 0 + for group, val of dailyRevenue.groups + groupMap[group] = true + dayGroupCountMap[dailyRevenue.day][group] = val + dayGroupCountMap[dailyRevenue.day]['Daily'] += val + @revenueGroups = Object.keys(groupMap) + @revenueGroups.push 'Daily' + # Build list of recurring revenue entries, where each entry is a day of individual group values + @revenue = [] + for day of dayGroupCountMap + dashedDay = "#{day.substring(0, 4)}-#{day.substring(4, 6)}-#{day.substring(6, 8)}" + data = day: dashedDay, groups: [] + for group in @revenueGroups + data.groups.push(dayGroupCountMap[day][group] ? 0) + @revenue.push data + @revenue.sort (a, b) -> b.day.localeCompare(a.day) + + return unless @revenue.length > 0 + + # Add monthly recurring revenue values + @revenueGroups.push 'Monthly' + monthlyValues = [] + for i in [@revenue.length-1..0] + dailyTotal = @revenue[i].groups[@revenue[i].groups.length - 1] + monthlyValues.push(dailyTotal) + monthlyValues.shift() while monthlyValues.length > 30 + if monthlyValues.length is 30 + @revenue[i].groups.push(_.reduce(monthlyValues, (s, num) -> s + num)) + + @updateAllKPIChartData() + @updateRevenueChartData() + @render?() + }, 0).load() + + createLineChartPoints: (days, data) -> + points = [] + for entry, i in data + points.push + x: i + y: entry.value + day: entry.day + + # Ensure points for each day + for day, i in days + if points.length <= i or points[i].day isnt day + prevY = if i > 0 then points[i - 1].y else 0.0 + points.splice i, 0, + y: prevY + day: day + points[i].x = i + + points.splice(0, points.length - days.length) if points.length > days.length + points + + createLineCharts: -> + d3Utils.createLineChart('.kpi-recent-chart', @kpiRecentChartLines) + d3Utils.createLineChart('.kpi-chart', @kpiChartLines) + d3Utils.createLineChart('.active-classes-chart', @activeClassesChartLines) + d3Utils.createLineChart('.active-users-chart', @activeUsersChartLines) + d3Utils.createLineChart('.recurring-revenue-chart', @revenueChartLines) + + updateAllKPIChartData: -> + @kpiRecentChartLines = [] + @kpiChartLines = [] + @updateKPIChartData(60, @kpiRecentChartLines) + @updateKPIChartData(300, @kpiChartLines) + + updateKPIChartData: (timeframeDays, chartLines) -> + days = d3Utils.createContiguousDays(timeframeDays) + + if @activeClasses?.length > 0 + data = [] + for entry in @activeClasses + data.push + day: entry.day + value: entry.groups[entry.groups.length - 1] + data.reverse() + points = @createLineChartPoints(days, data) + chartLines.push + points: points + description: '30-day Active Classes' + lineColor: 'blue' + strokeWidth: 1 + min: 0 + max: _.max(points, 'y').y + showYScale: true + + if @revenue?.length > 0 + data = [] + for entry in @revenue + data.push + day: entry.day + value: entry.groups[entry.groups.length - 1] / 100000 + data.reverse() + points = @createLineChartPoints(days, data) + chartLines.push + points: points + description: '30-day Recurring Revenue (in thousands)' + lineColor: 'green' + strokeWidth: 1 + min: 0 + max: _.max(points, 'y').y + showYScale: true + + if @activeUsers?.length > 0 + data = [] + for entry in @activeUsers + break unless entry.monthlyCount + data.push + day: entry.day + value: entry.monthlyCount / 1000 + data.reverse() + points = @createLineChartPoints(days, data) + chartLines.push + points: points + description: '30-day Active Users (in thousands)' + lineColor: 'red' + strokeWidth: 1 + min: 0 + max: _.max(points, 'y').y + showYScale: true + + updateActiveClassesChartData: -> + @activeClassesChartLines = [] + return unless @activeClasses?.length + days = d3Utils.createContiguousDays(90) + + groupDayMap = {} + for entry in @activeClasses + for count, i in entry.groups + groupDayMap[@activeClassGroups[i]] ?= {} + groupDayMap[@activeClassGroups[i]][entry.day] ?= 0 + groupDayMap[@activeClassGroups[i]][entry.day] += count + + lines = [] + colorIndex = 0 + totalMax = 0 + for group, entries of groupDayMap + data = [] + for day, count of entries + data.push + day: day + value: count + data.reverse() + points = @createLineChartPoints(days, data) + @activeClassesChartLines.push + points: points + description: group.replace('Active classes ', '') + lineColor: @lineColors[colorIndex++ % @lineColors.length] + strokeWidth: 1 + min: 0 + showYScale: group is 'Total' + totalMax = _.max(points, 'y').y if group is 'Total' + line.max = totalMax for line in @activeClassesChartLines + + updateActiveUsersChartData: -> + @activeUsersChartLines = [] + return unless @activeUsers?.length + days = d3Utils.createContiguousDays(90) + + dailyData = [] + monthlyData = [] + dausmausData = [] + colorIndex = 0 + for entry in @activeUsers + dailyData.push + day: entry.day + value: entry.dailyCount / 1000 + if entry.monthlyCount + monthlyData.push + day: entry.day + value: entry.monthlyCount / 1000 + dausmausData.push + day: entry.day + value: Math.round(entry.dailyCount / entry.monthlyCount * 100) + dailyData.reverse() + monthlyData.reverse() + dausmausData.reverse() + dailyPoints = @createLineChartPoints(days, dailyData) + monthlyPoints = @createLineChartPoints(days, monthlyData) + dausmausPoints = @createLineChartPoints(days, dausmausData) + @activeUsersChartLines.push + points: dailyPoints + description: 'Daily active users (in thousands)' + lineColor: @lineColors[colorIndex++ % @lineColors.length] + strokeWidth: 1 + min: 0 + max: _.max(dailyPoints, 'y').y + showYScale: true + @activeUsersChartLines.push + points: monthlyPoints + description: 'Monthly active users (in thousands)' + lineColor: @lineColors[colorIndex++ % @lineColors.length] + strokeWidth: 1 + min: 0 + max: _.max(monthlyPoints, 'y').y + showYScale: true + @activeUsersChartLines.push + points: dausmausPoints + description: 'DAUs/MAUs %' + lineColor: @lineColors[colorIndex++ % @lineColors.length] + strokeWidth: 1 + min: 0 + max: _.max(dausmausPoints, 'y').y + showYScale: true + + updateRevenueChartData: -> + @revenueChartLines = [] + return unless @revenue?.length + days = d3Utils.createContiguousDays(90) + + groupDayMap = {} + for entry in @revenue + for count, i in entry.groups + groupDayMap[@revenueGroups[i]] ?= {} + groupDayMap[@revenueGroups[i]][entry.day] ?= 0 + groupDayMap[@revenueGroups[i]][entry.day] += count + + lines = [] + colorIndex = 0 + dailyMax = 0 + for group, entries of groupDayMap + data = [] + for day, count of entries + data.push + day: day + value: count / 100 + data.reverse() + points = @createLineChartPoints(days, data) + @revenueChartLines.push + points: points + description: group.replace('DRR ', '') + lineColor: @lineColors[colorIndex++ % @lineColors.length] + strokeWidth: 1 + min: 0 + max: _.max(points, 'y').y + showYScale: group in ['Daily', 'Monthly'] + dailyMax = _.max(points, 'y').y if group is 'Daily' + for line in @revenueChartLines when line.description isnt 'Monthly' + line.max = dailyMax From 717377eb437dce37a0161a3f9d05a62c985689c9 Mon Sep 17 00:00:00 2001 From: Matt Lott <mattlott@live.com> Date: Tue, 10 Nov 2015 14:54:39 -0800 Subject: [PATCH 3/6] Update analytics aggregation script Giving preference to payments created field over _id date. --- .../analytics/mongodb/queries/insertPerDayAnalytics.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js index 913709a17..f154e4805 100644 --- a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js +++ b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js @@ -642,7 +642,13 @@ function getRecurringRevenueCounts(startDay) { var cursor = db.payments.find({_id: {$gte: startObj}}); while (cursor.hasNext()) { var doc = cursor.next(); - var day = doc._id.getTimestamp().toISOString().substring(0, 10); + var day; + if (doc.created) { + day = doc.created.substring(0, 10); + } + else { + day = doc._id.getTimestamp().toISOString().substring(0, 10); + } if (doc.service === 'ios' || doc.service === 'bitcoin') continue; From 4497334e6d1d52f337cb1a035ca68db78cdd00bb Mon Sep 17 00:00:00 2001 From: Nick Winter <livelily@gmail.com> Date: Tue, 10 Nov 2015 15:21:34 -0800 Subject: [PATCH 4/6] Update a couple team avatar and descriptions --- README.md | 1 + app/locale/en.coffee | 4 ++-- app/templates/about.jade | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1e79276a0..3a9992acd 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Whether you're novice or pro, the CodeCombat team is ready to help you implement    +    diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 5b4674098..0a3f008b4 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -603,8 +603,8 @@ rob_blurb: "Codes things and stuff" josh_c_title: "Game Designer" josh_c_blurb: "Designs games" - carlos_title: "Region Manager" - carlos_blurb: "CodeCombat Brazil" + carlos_title: "Region Manager, Brazil" + carlos_blurb: "Celery Man" teachers: more_info: "More Info for Teachers" diff --git a/app/templates/about.jade b/app/templates/about.jade index bf836c1b0..1f5a98f07 100644 --- a/app/templates/about.jade +++ b/app/templates/about.jade @@ -201,6 +201,6 @@ block content h4.team_name | Carlos Maia p(data-i18n="about.carlos_title") - | Region Manager + | Region Manager, Brazil p(data-i18n="about.carlos_blurb") - | CodeCombat Brazil + | Celery Man From 22590768004f469f40bee89074c18241cccebf19 Mon Sep 17 00:00:00 2001 From: Nick Winter <livelily@gmail.com> Date: Tue, 10 Nov 2015 15:22:09 -0800 Subject: [PATCH 5/6] LoadingScreen -> level intro behavior --- app/core/utils.coffee | 36 ++++++ app/schemas/models/level_session.coffee | 6 +- app/styles/play/level.sass | 8 +- app/styles/play/level/loading.sass | 82 +++++++++----- app/styles/play/menu/guide-view.sass | 11 ++ app/templates/play/level/level_loading.jade | 4 +- app/views/contribute/ArchmageView.coffee | 5 +- app/views/play/level/LevelLoadingView.coffee | 104 +++++++++++++++--- app/views/play/level/PlayLevelView.coffee | 29 ++--- .../level/tome/SpellPaletteEntryView.coffee | 24 +--- app/views/play/level/tome/SpellView.coffee | 17 +-- app/views/play/menu/GuideView.coffee | 21 ++-- 12 files changed, 240 insertions(+), 107 deletions(-) diff --git a/app/core/utils.coffee b/app/core/utils.coffee index 94cbb152b..782d487d7 100644 --- a/app/core/utils.coffee +++ b/app/core/utils.coffee @@ -248,3 +248,39 @@ module.exports.getPrepaidCodeAmount = getPrepaidCodeAmount = (price=999, users=0 return 0 unless users > 0 and months > 0 total = price * users * months total + +module.exports.filterMarkdownCodeLanguages = (text) -> + currentLanguage = me.get('aceConfig')?.language or 'python' + excludedLanguages = _.without ['javascript', 'python', 'coffeescript', 'clojure', 'lua', 'io'], currentLanguage + exclusionRegex = new RegExp "```(#{excludedLanguages.join('|')})\n[^`]+```\n?", 'gm' + text.replace exclusionRegex, '' + +module.exports.aceEditModes = aceEditModes = + 'javascript': 'ace/mode/javascript' + 'coffeescript': 'ace/mode/coffee' + 'python': 'ace/mode/python' + 'clojure': 'ace/mode/clojure' + 'lua': 'ace/mode/lua' + 'io': 'ace/mode/text' + +module.exports.initializeACE = (el, codeLanguage) -> + contents = $(el).text().trim() + editor = ace.edit el + editor.setOptions maxLines: Infinity + editor.setReadOnly true + editor.setTheme 'ace/theme/textmate' + editor.setShowPrintMargin false + editor.setShowFoldWidgets false + editor.setHighlightActiveLine false + editor.setHighlightActiveLine false + editor.setBehavioursEnabled false + editor.renderer.setShowGutter false + editor.setValue contents + editor.clearSelection() + session = editor.getSession() + session.setUseWorker false + session.setMode aceEditModes[codeLanguage] + session.setWrapLimitRange null + session.setUseWrapMode true + session.setNewLineMode 'unix' + return editor diff --git a/app/schemas/models/level_session.coffee b/app/schemas/models/level_session.coffee index 66a3bddb8..e2caf8b87 100644 --- a/app/schemas/models/level_session.coffee +++ b/app/schemas/models/level_session.coffee @@ -79,15 +79,15 @@ _.extend LevelSessionSchema.properties, currentScriptOffset: type: 'number' - selected: + selected: # Not tracked any more, delete with old level types type: [ 'null' 'string' ] playing: - type: 'boolean' # Not tracked any more + type: 'boolean' # Not tracked any more, delete with old level types frame: - type: 'number' # Not tracked any more + type: 'number' # Not tracked any more, delete with old level types thangs: # ... what is this? Is this used? type: 'object' additionalProperties: diff --git a/app/styles/play/level.sass b/app/styles/play/level.sass index 82ad555d4..ad2a515c5 100644 --- a/app/styles/play/level.sass +++ b/app/styles/play/level.sass @@ -72,7 +72,13 @@ $level-resize-transition-time: 0.5s width: 55% position: relative overflow: hidden - @include transition($level-resize-transition-time ease-out) + @include transition(all $level-resize-transition-time ease-out, z-index 1.2s linear) + z-index: 0 + + &.preview-overlay + z-index: 20 + #goals-view + visibility: hidden canvas#webgl-surface background-color: #333 diff --git a/app/styles/play/level/loading.sass b/app/styles/play/level/loading.sass index 10c62bee3..5c8d6942b 100644 --- a/app/styles/play/level/loading.sass +++ b/app/styles/play/level/loading.sass @@ -8,24 +8,48 @@ background-position: top $backgroundPosition background-size: contain +$UNVEIL_TIME: 1.2s + #level-loading-view width: 100% height: 100% position: absolute z-index: 20 - $UNVEIL_TIME: 1.2s &.unveiled pointer-events: none - - .loading-details + + &.preview-screen + background-color: rgba(0, 0, 0, 0.5) + + .left-wing, .right-wing + width: 100% + height: 100% + position: absolute + pointer-events: none + + .left-wing + @include wing-background('/images/level/loading_left_wing_1920.jpg', right) + @media screen and ( max-width: 1366px ) + @include wing-background('/images/level/loading_left_wing_1366.jpg', right) + left: -50% + @include transition(all $UNVEIL_TIME ease) + + .right-wing + @include wing-background('/images/level/loading_right_wing_1920.jpg', left) + @media screen and ( max-width: 1366px ) + @include wing-background('/images/level/loading_right_wing_1366.jpg', left) + right: -50% + @include transition(all $UNVEIL_TIME ease) + + #loading-details position: absolute top: 86px - left: 50% + right: 50% $WIDTH: 450px width: $WIDTH height: 450px - margin-left: (-$WIDTH / 2) + margin-right: (-$WIDTH / 2) z-index: 100 background: transparent url(/images/level/code_editor_background.png) no-repeat background-size: 100% 100% @@ -34,9 +58,22 @@ padding: 80px 80px 40px 80px text-align: center // http://matthewlein.com/ceaser/ Bounce down a bit, then snap up. - @include transition(top $UNVEIL_TIME cubic-bezier(0.285, -0.595, 0.670, -0.600)) + @include transition($UNVEIL_TIME cubic-bezier(0.285, -0.595, 0.670, -0.600)) font-family: 'Open Sans Condensed' + &.preview + top: 0 + right: 0 + margin-right: 0 + width: 45% + height: auto + pointer-events: all + @include transition($UNVEIL_TIME ease-in-out) + + padding: 80px 70px 40px 50px + .progress-or-start-container.intro-footer + bottom: 30px + .level-loading-goals text-align: left @@ -49,12 +86,21 @@ font-size: 20px color: black + .intro-doc + text-align: left + font-size: 16px + overflow: scroll + + img + max-width: 100% + .progress-or-start-container position: absolute bottom: 95px - width: 325px height: 80px left: 48px + right: 77px + @include transition(bottom $UNVEIL_TIME ease-out) .load-progress width: 100% @@ -131,21 +177,7 @@ width: 401px color: #666 - .left-wing, .right-wing - width: 100% - height: 100% - position: absolute - - .left-wing - @include wing-background('/images/level/loading_left_wing_1920.jpg', right) - @media screen and ( max-width: 1366px ) - @include wing-background('/images/level/loading_left_wing_1366.jpg', right) - left: -50% - @include transition(all $UNVEIL_TIME ease) - - .right-wing - @include wing-background('/images/level/loading_right_wing_1920.jpg', left) - @media screen and ( max-width: 1366px ) - @include wing-background('/images/level/loading_right_wing_1366.jpg', left) - right: -50% - @include transition(all $UNVEIL_TIME ease) + &.preview #tip-wrapper + left: 48px + right: 77px + width: auto diff --git a/app/styles/play/menu/guide-view.sass b/app/styles/play/menu/guide-view.sass index 503097adf..25ce5b57b 100644 --- a/app/styles/play/menu/guide-view.sass +++ b/app/styles/play/menu/guide-view.sass @@ -52,3 +52,14 @@ border-image: url(/images/level/code_toolbar_submit_button_zazz_pressed.png) 14 20 20 20 fill round padding: 2px 0 0 2px color: white + +#guide-view + pre.ace_editor + padding: 2px 4px + border-radius: 4px + background-color: #f9f2f4 + font-size: 12px + font-family: Monaco, Menlo, Ubuntu Mono, Consolas, "source-code-pro", monospace !important + + .ace_cursor, .ace_bracket + display: none diff --git a/app/templates/play/level/level_loading.jade b/app/templates/play/level/level_loading.jade index 693cfc297..71ef46fd3 100644 --- a/app/templates/play/level/level_loading.jade +++ b/app/templates/play/level/level_loading.jade @@ -2,7 +2,7 @@ .right-wing -.loading-details.loading-container +#loading-details.loading-container .level-loading-goals.secret .goals-title(data-i18n="play_level.goals") Goals @@ -10,6 +10,8 @@ .errors + .intro-doc + .progress-or-start-container button.start-level-button.btn.btn-lg.btn-success.btn-illustrated.header-font.needsclick(data-i18n="play_level.loading_start") Start Level diff --git a/app/views/contribute/ArchmageView.coffee b/app/views/contribute/ArchmageView.coffee index 7a428f5f5..93cdc9401 100644 --- a/app/views/contribute/ArchmageView.coffee +++ b/app/views/contribute/ArchmageView.coffee @@ -7,6 +7,7 @@ module.exports = class ArchmageView extends ContributeClassView contributorClassName: 'archmage' contributors: [ + {id: '547acbb2af18b03c0563fdb3', name: 'David Liu', github: 'trotod'} {id: '52ccfc9bd3eb6b5a4100b60d', name: 'Glen De Cauwsemaecker', github: 'GlenDC'} {id: '52bfc3ecb7ec628868001297', name: 'Tom Steinbrecher', github: 'TomSteinbrecher'} {id: '5272806093680c5817033f73', name: 'Sébastien Moratinos', github: 'smoratinos'} @@ -27,8 +28,8 @@ module.exports = class ArchmageView extends ContributeClassView {id: '531258b5e0789d4609614110', name: 'Ruben Vereecken', github: 'rubenvereecken'} {id: '5276ad5dcf83207a2801d3b4', name: 'Zach Martin', github: 'zachster01'} {id: '530df0cbc06854403ba67c15', name: 'Alexandru Caciulescu', github: 'Darredevil'} - {id: '5268d9baa39d7db617000b18', name: 'Thanish Muhammed', github: 'mnmtanish'} - {id: '53232f458e54704b074b271d', name: 'Bang Honam', github: 'walkingtospace'} + {id: '5268d9baa39d7db617000b18', name: 'Thanish Muhammed', github: 'mnmtanish'} + {id: '53232f458e54704b074b271d', name: 'Bang Honam', github: 'walkingtospace'} {id: '52d16c1dc931e2544d001daa', name: 'David Pendray', github: 'dpen2000'} {id: '53132ea1828a1706108ebb38', name: 'Dominik Kundel'} {id: '530eb29347a891b3518b3990', name: 'Ian Li'} diff --git a/app/views/play/level/LevelLoadingView.coffee b/app/views/play/level/LevelLoadingView.coffee index 3a7211d9a..68a6ca701 100644 --- a/app/views/play/level/LevelLoadingView.coffee +++ b/app/views/play/level/LevelLoadingView.coffee @@ -14,6 +14,7 @@ module.exports = class LevelLoadingView extends CocoView subscriptions: 'level:loaded': 'onLevelLoaded' # If Level loads after level loading view. + 'level:session-loaded': 'onSessionLoaded' 'level:subscription-required': 'onSubscriptionRequired' # If they'd need a subscription to start playing. 'level:course-membership-required': 'onCourseMembershipRequired' # If they'd need a subscription to start playing. 'subscribe-modal:subscribed': 'onSubscribed' @@ -44,6 +45,14 @@ module.exports = class LevelLoadingView extends CocoView onLevelLoaded: (e) -> @level = e.level + @prepareGoals() + @prepareTip() + @prepareIntro() + + onSessionLoaded: (e) -> + @session = e.session if e.session.get('creator') is me.id + + prepareGoals: -> goalContainer = @$el.find('.level-loading-goals') goalList = goalContainer.find('ul') goalCount = 0 @@ -55,57 +64,120 @@ module.exports = class LevelLoadingView extends CocoView goalContainer.removeClass('secret') if goalCount is 1 goalContainer.find('.panel-heading').text $.i18n.t 'play_level.goal' # Not plural + + prepareTip: -> tip = @$el.find('.tip') if @level.get('loadingTip') loadingTip = utils.i18n @level.attributes, 'loadingTip' tip.text(loadingTip) tip.removeClass('secret') + prepareIntro: -> + @docs = @level.get('documentation') ? {} + specific = @docs.specificArticles or [] + return unless @intro = _.find specific, name: 'Intro' + @intro.html = marked utils.filterMarkdownCodeLanguages(utils.i18n(@intro, 'body')) + @intro.name = utils.i18n @intro, 'name' + showReady: -> return if @shownReady @shownReady = true - _.delay @finishShowingReady, 1500 # Let any blocking JS hog the main thread before we show that we're done. + _.delay @finishShowingReady, 100 # Let any blocking JS hog the main thread before we show that we're done. finishShowingReady: => return if @destroyed - if @options.autoUnveil + if @options.autoUnveil or (@session?.get('state').complete and not @getQueryVariable('intro')) @startUnveiling() - @unveil() + @unveil true else @playSound 'level_loaded', 0.75 # old: loading_ready @$el.find('.progress').hide() @$el.find('.start-level-button').show() + @unveil false startUnveiling: (e) -> @playSound 'menu-button-click' + @unveiling = true Backbone.Mediator.publish 'level:loading-view-unveiling', {} _.delay @onClickStartLevel, 1000 # If they never mouse-up for the click (or a modal shows up and interrupts the click), do it anyway. onClickStartLevel: (e) => return if @destroyed - @unveil() + @unveil true onEnterPressed: (e) -> - return unless @shownReady and not @$el.hasClass 'unveiled' + return unless @shownReady and not @unveiled @startUnveiling() @onClickStartLevel() - unveil: -> - return if @$el.hasClass 'unveiled' - @$el.addClass 'unveiled' - loadingDetails = @$el.find('.loading-details') - duration = parseFloat loadingDetails.css 'transition-duration' - loadingDetails.css 'top', -loadingDetails.outerHeight(true) + unveil: (full) -> + return if @destroyed or @unveiled + @unveiled = full + @$loadingDetails = @$el.find('#loading-details') + duration = parseFloat(@$loadingDetails.css 'transition-duration') * 1000 + unless @$el.hasClass 'unveiled' + @$el.addClass 'unveiled' + @unveilWings duration + if full + @unveilLoadingFull() + _.delay @onUnveilEnded, duration + else + @unveilLoadingPreview duration + + unveilLoadingFull: -> + # Get rid of the loading details screen entirely--the level is totally ready. + unless @unveiling + Backbone.Mediator.publish 'level:loading-view-unveiling', {} + @unveiling = true + if @$el.hasClass 'preview-screen' + @$loadingDetails.css 'right', -@$loadingDetails.outerWidth(true) + else + @$loadingDetails.css 'top', -@$loadingDetails.outerHeight(true) + @$el.removeClass 'preview-screen' + $('#canvas-wrapper').removeClass 'preview-overlay' + + unveilLoadingPreview: (duration) -> + # Move the loading details screen over the code editor to preview the level. + return if @$el.hasClass 'preview-screen' + $('#canvas-wrapper').addClass 'preview-overlay' + @$el.addClass('preview-screen') + @$loadingDetails.addClass('preview') + @resize() + @onWindowResize = _.debounce @onWindowResize, 700 # Wait a bit for other views to resize before we resize + $(window).on 'resize', @onWindowResize + if @intro + @$el.find('.progress-or-start-container').addClass('intro-footer') + @$el.find('#tip-wrapper').remove() + _.delay @unveilIntro, duration + + resize: -> + maxHeight = $('#page-container').outerHeight(true) + minHeight = $('#code-area').outerHeight(true) + @$el.css height: maxHeight + @$loadingDetails.css minHeight: minHeight, maxHeight: maxHeight + $intro = @$el.find('.intro-doc') + $intro.css maxHeight: minHeight - $intro.offset().top - @$el.find('.progress-or-start-container').outerHeight() - 30 - 20 + + unveilWings: (duration) -> + @playSound 'loading-view-unveil', 0.5 @$el.find('.left-wing').css left: '-100%', backgroundPosition: 'right -400px top 0' @$el.find('.right-wing').css right: '-100%', backgroundPosition: 'left -400px top 0' - @playSound 'loading-view-unveil', 0.5 - _.delay @onUnveilEnded, duration * 1000 - $('#level-footer-background').detach().appendTo('#page-container').slideDown(duration * 1000) + $('#level-footer-background').detach().appendTo('#page-container').slideDown(duration) + + unveilIntro: => + return if @destroyed or not @intro or @unveiled + @$el.find('.intro-doc').html @intro.html + @resize() onUnveilEnded: => return if @destroyed Backbone.Mediator.publish 'level:loading-view-unveiled', view: @ + onWindowResize: (e) => + return if @destroyed + @$loadingDetails.css transition: 'none' + @resize() + onSubscriptionRequired: (e) -> @$el.find('.level-loading-goals, .tip, .load-progress').hide() @$el.find('.subscription-required').show() @@ -120,3 +192,7 @@ module.exports = class LevelLoadingView extends CocoView onSubscribed: -> document.location.reload() + + destroy: -> + $(window).off 'resize', @onWindowResize + super() diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee index 627245cb3..f4f43f450 100644 --- a/app/views/play/level/PlayLevelView.coffee +++ b/app/views/play/level/PlayLevelView.coffee @@ -155,7 +155,7 @@ module.exports = class PlayLevelView extends RootView afterRender: -> super() window.onPlayLevelViewLoaded? @ # still a hack - @insertSubView @loadingView = new LevelLoadingView autoUnveil: @options.autoUnveil or @observing, level: @levelLoader?.level ? @level # May not have @level loaded yet + @insertSubView @loadingView = new LevelLoadingView autoUnveil: @options.autoUnveil or @observing, level: @levelLoader?.level ? @level, session: @levelLoader?.session ? @session # May not have @level loaded yet @$el.find('#level-done-button').hide() $('body').addClass('is-playing') $('body').bind('touchmove', false) if @isIPadApp() @@ -177,7 +177,6 @@ module.exports = class PlayLevelView extends RootView @initVolume() @listenTo(@session, 'change:multiplayer', @onMultiplayerChanged) - @originalSessionState = $.extend(true, {}, @session.get('state')) @register() @controlBar.setBus(@bus) @initScriptManager() @@ -341,14 +340,16 @@ module.exports = class PlayLevelView extends RootView if window.currentModal and not window.currentModal.destroyed and window.currentModal.constructor isnt VictoryModal return Backbone.Mediator.subscribeOnce 'modal:closed', @onLevelStarted, @ @surface.showLevel() + Backbone.Mediator.publish 'level:set-time', time: 0 if @isEditorPreview or @observing @loadingView.startUnveiling() - @loadingView.unveil() + @loadingView.unveil true onLoadingViewUnveiling: (e) -> - @restoreSessionState() + @selectHero() onLoadingViewUnveiled: (e) -> + Backbone.Mediator.publish 'level:set-playing', playing: true @loadingView.$el.remove() @removeSubView @loadingView @loadingView = null @@ -372,21 +373,11 @@ module.exports = class PlayLevelView extends RootView @ambientSound = createjs.Sound.play src, loop: -1, volume: 0.1 createjs.Tween.get(@ambientSound).to({volume: 1.0}, 10000) - restoreSessionState: -> - return if @alreadyLoadedState - @alreadyLoadedState = true - state = @originalSessionState - if not @level or @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder'] - Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: true - Backbone.Mediator.publish 'tome:select-primary-sprite', {} - Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: false - @surface.focusOnHero() - Backbone.Mediator.publish 'level:set-time', time: 0 - Backbone.Mediator.publish 'level:set-playing', playing: true - else - if state.selected - # TODO: Should also restore selected spell here by saving spellName - Backbone.Mediator.publish 'level:select-sprite', thangID: state.selected, spellName: null + selectHero: -> + Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: true + Backbone.Mediator.publish 'tome:select-primary-sprite', {} + Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: false + @surface.focusOnHero() # callbacks diff --git a/app/views/play/level/tome/SpellPaletteEntryView.coffee b/app/views/play/level/tome/SpellPaletteEntryView.coffee index 27c505502..9cdc15491 100644 --- a/app/views/play/level/tome/SpellPaletteEntryView.coffee +++ b/app/views/play/level/tome/SpellPaletteEntryView.coffee @@ -3,7 +3,7 @@ template = require 'templates/play/level/tome/spell_palette_entry' {me} = require 'core/auth' filters = require 'lib/image_filter' DocFormatter = require './DocFormatter' -SpellView = require 'views/play/level/tome/SpellView' +utils = require 'core/utils' module.exports = class SpellPaletteEntryView extends CocoView tagName: 'div' # Could also try <code> instead of <div>, but would need to adjust colors @@ -59,26 +59,8 @@ module.exports = class SpellPaletteEntryView extends CocoView @aceEditors = [] aceEditors = @aceEditors popover?.$tip?.find('.docs-ace').each -> - contents = $(@).text() - editor = ace.edit @ - editor.setOptions maxLines: Infinity - editor.setReadOnly true - editor.setTheme 'ace/theme/textmate' - editor.setShowPrintMargin false - editor.setShowFoldWidgets false - editor.setHighlightActiveLine false - editor.setHighlightActiveLine false - editor.setBehavioursEnabled false - editor.renderer.setShowGutter false - editor.setValue contents - editor.clearSelection() - session = editor.getSession() - session.setUseWorker false - session.setMode SpellView.editModes[codeLanguage] - session.setWrapLimitRange null - session.setUseWrapMode true - session.setNewLineMode 'unix' - aceEditors.push editor + aceEditor = utils.initializeACE @, codeLanguage + aceEditors.push aceEditor onMouseEnter: (e) -> # Make sure the doc has the updated Thang so it can regenerate its prop value diff --git a/app/views/play/level/tome/SpellView.coffee b/app/views/play/level/tome/SpellView.coffee index 1e7214182..95be903e7 100644 --- a/app/views/play/level/tome/SpellView.coffee +++ b/app/views/play/level/tome/SpellView.coffee @@ -9,6 +9,7 @@ SpellDebugView = require './SpellDebugView' SpellToolbarView = require './SpellToolbarView' LevelComponent = require 'models/LevelComponent' UserCodeProblem = require 'models/UserCodeProblem' +utils = require 'core/utils' module.exports = class SpellView extends CocoView id: 'spell-view' @@ -18,14 +19,6 @@ module.exports = class SpellView extends CocoView eventsSuppressed: true writable: true - @editModes: - 'javascript': 'ace/mode/javascript' - 'coffeescript': 'ace/mode/coffee' - 'python': 'ace/mode/python' - 'clojure': 'ace/mode/clojure' - 'lua': 'ace/mode/lua' - 'io': 'ace/mode/text' - keyBindings: 'default': null 'vim': 'ace/keyboard/vim' @@ -93,7 +86,7 @@ module.exports = class SpellView extends CocoView @aceSession = @ace.getSession() @aceDoc = @aceSession.getDocument() @aceSession.setUseWorker false - @aceSession.setMode SpellView.editModes[@spell.language] + @aceSession.setMode utils.aceEditModes[@spell.language] @aceSession.setWrapLimitRange null @aceSession.setUseWrapMode true @aceSession.setNewLineMode 'unix' @@ -479,7 +472,7 @@ module.exports = class SpellView extends CocoView # window.zatannaInstance = @zatanna # For debugging. Make sure to not leave active when committing. # window.snippetEntries = snippetEntries - lang = SpellView.editModes[e.language].substr 'ace/mode/'.length + lang = utils.aceEditModes[e.language].substr 'ace/mode/'.length @zatanna.addSnippets snippetEntries, lang @editorLang = lang @@ -1138,8 +1131,8 @@ module.exports = class SpellView extends CocoView onChangeLanguage: (e) -> return unless @spell.canWrite() - @aceSession.setMode SpellView.editModes[e.language] - @zatanna?.set 'language', SpellView.editModes[e.language].substr('ace/mode/') + @aceSession.setMode utils.aceEditModes[e.language] + @zatanna?.set 'language', utils.aceEditModes[e.language].substr('ace/mode/') wasDefault = @getSource() is @spell.originalSource @spell.setLanguage e.language @reloadCode true if wasDefault diff --git a/app/views/play/menu/GuideView.coffee b/app/views/play/menu/GuideView.coffee index 606c586b4..b1d727081 100644 --- a/app/views/play/menu/GuideView.coffee +++ b/app/views/play/menu/GuideView.coffee @@ -4,8 +4,6 @@ Article = require 'models/Article' SubscribeModal = require 'views/core/SubscribeModal' utils = require 'core/utils' -# let's implement this once we have the docs database schema set up - module.exports = class LevelGuideView extends CocoView template: template id: 'guide-view' @@ -41,10 +39,10 @@ module.exports = class LevelGuideView extends CocoView @docs = specific.concat(general) @docs = $.extend(true, [], @docs) @docs = [@docs[0]] if @firstOnly and @docs[0] - doc.html = marked(@filterCodeLanguages(utils.i18n(doc, 'body'))) for doc in @docs + doc.html = marked(utils.filterMarkdownCodeLanguages(utils.i18n(doc, 'body'))) for doc in @docs doc.name = (utils.i18n doc, 'name') for doc in @docs doc.slug = _.string.slugify(doc.name) for doc in @docs - super() + super options destroy: -> if @vimeoListenerAttached @@ -52,6 +50,7 @@ module.exports = class LevelGuideView extends CocoView window.removeEventListener('message', @onMessageReceived, false) else window.detachEvent('onmessage', @onMessageReceived, false) + oldEditor.destroy() for oldEditor in @aceEditors ? [] super() getRenderData: -> @@ -70,13 +69,17 @@ module.exports = class LevelGuideView extends CocoView @$el.find('.nav-tabs li:first').addClass('active') @$el.find('.tab-content .tab-pane:first').addClass('active') @$el.find('.nav-tabs a').click(@clickTab) + @configureACEEditors() @playSound 'guide-open' - filterCodeLanguages: (text) -> - currentLanguage = me.get('aceConfig')?.language or 'python' - excludedLanguages = _.without ['javascript', 'python', 'coffeescript', 'clojure', 'lua', 'io'], currentLanguage - exclusionRegex = new RegExp "```(#{excludedLanguages.join('|')})\n[^`]+```\n?", 'gm' - text.replace exclusionRegex, '' + configureACEEditors: -> + oldEditor.destroy() for oldEditor in @aceEditors ? [] + @aceEditors = [] + aceEditors = @aceEditors + codeLanguage = me.get('aceConfig')?.language or 'python' + @$el.find('pre').each -> + aceEditor = utils.initializeACE @, codeLanguage + aceEditors.push aceEditor clickSubscribe: (e) -> level = @levelSlug # Save ref to level slug From 7e433b9e1246027b2974577805ba45ba80766912 Mon Sep 17 00:00:00 2001 From: Nick Winter <livelily@gmail.com> Date: Tue, 10 Nov 2015 16:09:21 -0800 Subject: [PATCH 6/6] Use ?intro=true to show intro screen even after level completion --- app/views/play/level/LevelLoadingView.coffee | 11 ++++++----- app/views/play/level/PlayLevelView.coffee | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/views/play/level/LevelLoadingView.coffee b/app/views/play/level/LevelLoadingView.coffee index 68a6ca701..fde29cd63 100644 --- a/app/views/play/level/LevelLoadingView.coffee +++ b/app/views/play/level/LevelLoadingView.coffee @@ -75,9 +75,7 @@ module.exports = class LevelLoadingView extends CocoView prepareIntro: -> @docs = @level.get('documentation') ? {} specific = @docs.specificArticles or [] - return unless @intro = _.find specific, name: 'Intro' - @intro.html = marked utils.filterMarkdownCodeLanguages(utils.i18n(@intro, 'body')) - @intro.name = utils.i18n @intro, 'name' + @intro = _.find specific, name: 'Intro' showReady: -> return if @shownReady @@ -86,7 +84,9 @@ module.exports = class LevelLoadingView extends CocoView finishShowingReady: => return if @destroyed - if @options.autoUnveil or (@session?.get('state').complete and not @getQueryVariable('intro')) + showIntro = @getQueryVariable('intro') + autoUnveil = not showIntro and (@options.autoUnveil or @session?.get('state').complete) + if autoUnveil @startUnveiling() @unveil true else @@ -166,7 +166,8 @@ module.exports = class LevelLoadingView extends CocoView unveilIntro: => return if @destroyed or not @intro or @unveiled - @$el.find('.intro-doc').html @intro.html + html = marked utils.filterMarkdownCodeLanguages(utils.i18n(@intro, 'body')) + @$el.find('.intro-doc').html html @resize() onUnveilEnded: => diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee index f4f43f450..78828d08d 100644 --- a/app/views/play/level/PlayLevelView.coffee +++ b/app/views/play/level/PlayLevelView.coffee @@ -341,7 +341,7 @@ module.exports = class PlayLevelView extends RootView return Backbone.Mediator.subscribeOnce 'modal:closed', @onLevelStarted, @ @surface.showLevel() Backbone.Mediator.publish 'level:set-time', time: 0 - if @isEditorPreview or @observing + if (@isEditorPreview or @observing) and not @getQueryVariable('intro') @loadingView.startUnveiling() @loadingView.unveil true