Merge remote-tracking branch 'origin/master' into afc-update-user-references

This commit is contained in:
Alberto Fernández Capel 2018-06-08 11:28:58 +01:00
commit 57775e60b1
130 changed files with 4315 additions and 1011 deletions

View file

@ -180,7 +180,7 @@ clean_css:
rm -f public/stylesheets/*.css*
clean_ci:
docker-compose down -v
docker-compose down -v -t 0
test: test_unit test_frontend test_acceptance
@ -204,7 +204,7 @@ test_acceptance_app_start_service: test_clean # stop service and clear dbs
docker-compose ${DOCKER_COMPOSE_FLAGS} up -d test_acceptance
test_acceptance_app_stop_service:
docker-compose ${DOCKER_COMPOSE_FLAGS} stop test_acceptance redis mongo
docker-compose ${DOCKER_COMPOSE_FLAGS} stop -t 0 test_acceptance redis mongo
test_acceptance_app_run:
docker-compose ${DOCKER_COMPOSE_FLAGS} exec -T test_acceptance npm -q run test:acceptance -- ${MOCHA_ARGS}
@ -224,7 +224,7 @@ test_acceptance_module: $(MODULE_MAKEFILES)
fi
test_clean:
docker-compose ${DOCKER_COMPOSE_FLAGS} down -v
docker-compose ${DOCKER_COMPOSE_FLAGS} down -v -t 0
ci:
MOCHA_ARGS="--reporter tap" \

View file

@ -62,7 +62,7 @@ test_acceptance_start_service: test_acceptance_stop_service
$(DOCKER_COMPOSE) up -d test_acceptance
test_acceptance_stop_service:
$(DOCKER_COMPOSE) stop test_acceptance redis mongo
$(DOCKER_COMPOSE) stop -t 0 test_acceptance redis mongo
test_acceptance_run:
$(DOCKER_COMPOSE) exec -T test_acceptance npm -q run test:acceptance:dir -- ${MOCHA_ARGS} $(MODULE_DIR)/test/acceptance/js

View file

@ -1,5 +1,5 @@
BetaProgramHandler = require './BetaProgramHandler'
UserLocator = require "../User/UserLocator"
UserGetter = require "../User/UserGetter"
Settings = require "settings-sharelatex"
logger = require 'logger-sharelatex'
AuthenticationController = require '../Authentication/AuthenticationController'
@ -30,7 +30,7 @@ module.exports = BetaProgramController =
optInPage: (req, res, next)->
user_id = AuthenticationController.getLoggedInUserId(req)
logger.log {user_id}, "showing beta participation page for user"
UserLocator.findById user_id, (err, user)->
UserGetter.getUser user_id, (err, user)->
if err
logger.err {err, user_id}, "error fetching user"
return next(err)

View file

@ -27,7 +27,7 @@ module.exports = CollaboratorsInviteController =
_checkShouldInviteEmail: (email, callback=(err, shouldAllowInvite)->) ->
if Settings.restrictInvitesToExistingAccounts == true
logger.log {email}, "checking if user exists with this email"
UserGetter.getUser {email: email}, {_id: 1}, (err, user) ->
UserGetter.getUserByMainEmail email, {_id: 1}, (err, user) ->
return callback(err) if err?
userExists = user? and user?._id?
callback(null, userExists)

View file

@ -32,7 +32,7 @@ module.exports = CollaboratorsInviteHandler =
_trySendInviteNotification: (projectId, sendingUser, invite, callback=(err)->) ->
email = invite.email
UserGetter.getUser {email: email}, {_id: 1}, (err, existingUser) ->
UserGetter.getUserByMainEmail email, {_id: 1}, (err, existingUser) ->
if err?
logger.err {projectId, email}, "error checking if user exists"
return callback(err)

View file

@ -7,7 +7,17 @@ module.exports =
exportProject: (req, res) ->
{project_id, brand_variation_id} = req.params
user_id = AuthenticationController.getLoggedInUserId(req)
ExportsHandler.exportProject project_id, user_id, brand_variation_id, (err, export_data) ->
export_params = {
project_id: project_id,
brand_variation_id: brand_variation_id,
user_id: user_id
}
if req.body && req.body.firstName && req.body.lastName
export_params.first_name = req.body.firstName.trim()
export_params.last_name = req.body.lastName.trim()
ExportsHandler.exportProject export_params, (err, export_data) ->
return next(err) if err?
logger.log
user_id:user_id

View file

@ -10,8 +10,8 @@ settings = require 'settings-sharelatex'
module.exports = ExportsHandler = self =
exportProject: (project_id, user_id, brand_variation_id, callback=(error, export_data) ->) ->
self._buildExport project_id, user_id, brand_variation_id, (err, export_data) ->
exportProject: (export_params, callback=(error, export_data) ->) ->
self._buildExport export_params, (err, export_data) ->
return callback(err) if err?
self._requestExport export_data, (err, export_v1_id) ->
return callback(err) if err?
@ -19,7 +19,10 @@ module.exports = ExportsHandler = self =
# TODO: possibly store the export data in Mongo
callback null, export_data
_buildExport: (project_id, user_id, brand_variation_id, callback=(err, export_data) ->) ->
_buildExport: (export_params, callback=(err, export_data) ->) ->
project_id = export_params.project_id
user_id = export_params.user_id
brand_variation_id = export_params.brand_variation_id
jobs =
project: (cb) ->
ProjectGetter.getProject project_id, cb
@ -43,6 +46,10 @@ module.exports = ExportsHandler = self =
logger.err err:err, project_id: project_id
return callback(err)
if export_params.first_name && export_params.last_name
user.first_name = export_params.first_name
user.last_name = export_params.last_name
export_data =
project:
id: project_id

View file

@ -5,7 +5,8 @@ logger = require 'logger-sharelatex'
module.exports = LinkedFilesController = {
Agents: {
url: require('./UrlAgent')
url: require('./UrlAgent'),
project_file: require('./ProjectFileAgent')
}
createLinkedFile: (req, res, next) ->
@ -22,11 +23,17 @@ module.exports = LinkedFilesController = {
linkedFileData = Agent.sanitizeData(data)
linkedFileData.provider = provider
Agent.checkAuth project_id, linkedFileData, user_id, (err, allowed) ->
return Agent.handleError(err, req, res, next) if err?
return res.sendStatus(403) if !allowed
Agent.decorateLinkedFileData linkedFileData, (err, newLinkedFileData) ->
return Agent.handleError(err) if err?
linkedFileData = newLinkedFileData
Agent.writeIncomingFileToDisk project_id, linkedFileData, user_id, (error, fsPath) ->
if error?
logger.error {err: error, project_id, name, linkedFileData, parent_folder_id, user_id}, 'error writing linked file to disk'
return Agent.handleError(error, req, res, next)
EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error) ->
EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error, file) ->
return next(error) if error?
res.send(204) # created
}
res.json(new_file_id: file._id) # created
}

View file

@ -0,0 +1,127 @@
FileWriter = require('../../infrastructure/FileWriter')
AuthorizationManager = require('../Authorization/AuthorizationManager')
ProjectLocator = require('../Project/ProjectLocator')
ProjectGetter = require('../Project/ProjectGetter')
DocstoreManager = require('../Docstore/DocstoreManager')
FileStoreHandler = require('../FileStore/FileStoreHandler')
FileWriter = require('../../infrastructure/FileWriter')
_ = require "underscore"
Settings = require 'settings-sharelatex'
AccessDeniedError = (message) ->
error = new Error(message)
error.name = 'AccessDenied'
error.__proto__ = AccessDeniedError.prototype
return error
AccessDeniedError.prototype.__proto__ = Error.prototype
BadEntityTypeError = (message) ->
error = new Error(message)
error.name = 'BadEntityType'
error.__proto__ = BadEntityTypeError.prototype
return error
BadEntityTypeError.prototype.__proto__ = Error.prototype
BadDataError = (message) ->
error = new Error(message)
error.name = 'BadData'
error.__proto__ = BadDataError.prototype
return error
BadDataError.prototype.__proto__ = Error.prototype
ProjectNotFoundError = (message) ->
error = new Error(message)
error.name = 'ProjectNotFound'
error.__proto__ = ProjectNotFoundError.prototype
return error
ProjectNotFoundError.prototype.__proto__ = Error.prototype
SourceFileNotFoundError = (message) ->
error = new Error(message)
error.name = 'BadData'
error.__proto__ = SourceFileNotFoundError.prototype
return error
SourceFileNotFoundError.prototype.__proto__ = Error.prototype
module.exports = ProjectFileAgent =
sanitizeData: (data) ->
return _.pick(
data,
'source_project_id',
'source_entity_path'
)
_validate: (data) ->
return (
data.source_project_id? &&
data.source_entity_path?
)
decorateLinkedFileData: (data, callback = (err, newData) ->) ->
callback = _.once(callback)
{ source_project_id } = data
return callback(new BadDataError()) if !source_project_id?
ProjectGetter.getProject source_project_id, (err, project) ->
return callback(err) if err?
return callback(new ProjectNotFoundError()) if !project?
callback(err, _.extend(data, {source_project_display_name: project.name}))
checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) ->
callback = _.once(callback)
if !ProjectFileAgent._validate(data)
return callback(new BadDataError())
{source_project_id, source_entity_path} = data
AuthorizationManager.canUserReadProject current_user_id, source_project_id, null, (err, canRead) ->
return callback(err) if err?
callback(null, canRead)
writeIncomingFileToDisk:
(project_id, data, current_user_id, callback = (error, fsPath) ->) ->
callback = _.once(callback)
if !ProjectFileAgent._validate(data)
return callback(new BadDataError())
{source_project_id, source_entity_path} = data
ProjectLocator.findElementByPath {
project_id: source_project_id,
path: source_entity_path
}, (err, entity, type) ->
if err?
if err.toString().match(/^not found.*/)
err = new SourceFileNotFoundError()
return callback(err)
ProjectFileAgent._writeEntityToDisk source_project_id, entity._id, type, callback
_writeEntityToDisk: (project_id, entity_id, type, callback=(err, location)->) ->
callback = _.once(callback)
if type == 'doc'
DocstoreManager.getDoc project_id, entity_id, (err, lines) ->
return callback(err) if err?
FileWriter.writeLinesToDisk entity_id, lines, callback
else if type == 'file'
FileStoreHandler.getFileStream project_id, entity_id, null, (err, fileStream) ->
return callback(err) if err?
FileWriter.writeStreamToDisk entity_id, fileStream, callback
else
callback(new BadEntityTypeError())
handleError: (error, req, res, next) ->
if error instanceof AccessDeniedError
res.status(403).send("You do not have access to this project")
else if error instanceof BadDataError
res.status(400).send("The submitted data is not valid")
else if error instanceof BadEntityTypeError
res.status(400).send("The file is the wrong type")
else if error instanceof SourceFileNotFoundError
res.status(404).send("Source file not found")
else if error instanceof ProjectNotFoundError
res.status(404).send("Project not found")
else
next(error)
next()

View file

@ -27,6 +27,12 @@ module.exports = UrlAgent = {
url: @._prependHttpIfNeeded(data.url)
}
decorateLinkedFileData: (data, callback = (err, newData) ->) ->
return callback(null, data)
checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) ->
callback(null, true)
writeIncomingFileToDisk: (project_id, data, current_user_id, callback = (error, fsPath) ->) ->
callback = _.once(callback)
url = data.url

View file

@ -9,7 +9,7 @@ logger = require("logger-sharelatex")
module.exports =
generateAndEmailResetToken:(email, callback = (error, exists) ->)->
UserGetter.getUser email:email, (err, user)->
UserGetter.getUserByMainEmail email, (err, user)->
if err then return callback(err)
if !user? or user.holdingAccount
logger.err email:email, "user could not be found for password reset"

View file

@ -25,6 +25,7 @@ Sources = require "../Authorization/Sources"
TokenAccessHandler = require '../TokenAccess/TokenAccessHandler'
CollaboratorsHandler = require '../Collaborators/CollaboratorsHandler'
Modules = require '../../infrastructure/Modules'
ProjectEntityHandler = require './ProjectEntityHandler'
crypto = require 'crypto'
module.exports = ProjectController =
@ -138,6 +139,33 @@ module.exports = ProjectController =
return next(err) if err?
res.sendStatus 200
userProjectsJson: (req, res, next) ->
user_id = AuthenticationController.getLoggedInUserId(req)
ProjectGetter.findAllUsersProjects user_id,
'name lastUpdated publicAccesLevel archived owner_ref tokens', (err, projects) ->
return next(err) if err?
projects = ProjectController._buildProjectList(projects)
.filter((p) -> !p.archived)
.filter((p) -> !p.isV1Project)
.map((p) -> {_id: p.id, name: p.name, accessLevel: p.accessLevel})
res.json({projects: projects})
projectEntitiesJson: (req, res, next) ->
user_id = AuthenticationController.getLoggedInUserId(req)
project_id = req.params.Project_id
ProjectGetter.getProject project_id, (err, project) ->
return next(err) if err?
ProjectEntityHandler.getAllEntitiesFromProject project, (err, docs, files) ->
return next(err) if err?
entities = docs.concat(files)
.sort (a, b) -> a.path > b.path # Sort by path ascending
.map (e) -> {
path: e.path,
type: if e.doc? then 'doc' else 'file'
}
res.json({project_id: project_id, entities: entities})
projectListPage: (req, res, next)->
timer = new metrics.Timer("project-list")
user_id = AuthenticationController.getLoggedInUserId(req)
@ -313,6 +341,7 @@ module.exports = ProjectController =
maxDocLength: Settings.max_doc_length
useV2History: !!project.overleaf?.history?.display
showRichText: req.query?.rt == 'true'
showTestControls: req.query?.tc == 'true' || user.isAdmin
showPublishModal: req.query?.pm == 'true'
timer.done()

View file

@ -45,13 +45,12 @@ wrapWithLock = (methodWithoutLock) ->
methodWithLock
module.exports = ProjectEntityUpdateHandler = self =
# this doesn't need any locking because it's only called by ProjectDuplicator
copyFileFromExistingProjectWithProject: (project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)->
copyFileFromExistingProjectWithProject: wrapWithLock
beforeLock: (next) ->
(project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)->
project_id = project._id
projectHistoryId = project.overleaf?.history?.id
logger.log { project_id, folder_id, originalProject_id, origonalFileRef }, "copying file in s3 with project"
return callback(err) if err?
ProjectEntityMongoUpdateHandler._confirmFolder project, folder_id, (folder_id)=>
ProjectEntityMongoUpdateHandler._confirmFolder project, folder_id, (folder_id) ->
if !origonalFileRef?
logger.err { project_id, folder_id, originalProject_id, origonalFileRef }, "file trying to copy is null"
return callback()
@ -61,7 +60,11 @@ module.exports = ProjectEntityUpdateHandler = self =
if err?
logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error coping file in s3"
return callback(err)
ProjectEntityMongoUpdateHandler._putElement project, folder_id, fileRef, "file", (err, result)=>
next(project, folder_id, originalProject_id, origonalFileRef, userId, fileRef, fileStoreUrl, callback)
withLock: (project, folder_id, originalProject_id, origonalFileRef, userId, fileRef, fileStoreUrl, callback = (error, fileRef, folder_id) ->)->
project_id = project._id
projectHistoryId = project.overleaf?.history?.id
ProjectEntityMongoUpdateHandler._putElement project, folder_id, fileRef, "file", (err, result, newProject) ->
if err?
logger.err { err, project_id, folder_id }, "error putting element as part of copy"
return callback(err)
@ -73,7 +76,7 @@ module.exports = ProjectEntityUpdateHandler = self =
path: result?.path?.fileSystem
url: fileStoreUrl
]
DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {newFiles}, (error) ->
DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {newFiles, newProject}, (error) ->
return callback(error) if error?
callback null, fileRef, folder_id

View file

@ -10,6 +10,8 @@ Async = require('async')
oneMinInMs = 60 * 1000
fiveMinsInMs = oneMinInMs * 5
if !settings.apis?.references?.url?
logger.log "references search not enabled"
module.exports = ReferencesHandler =

View file

@ -11,7 +11,16 @@ V1SubscriptionManager = require("./V1SubscriptionManager")
oneMonthInSeconds = 60 * 60 * 24 * 30
module.exports = FeaturesUpdater =
refreshFeatures: (user_id, callback)->
refreshFeatures: (user_id, notifyV1 = true, callback = () ->)->
if typeof notifyV1 == 'function'
callback = notifyV1
notifyV1 = true
if notifyV1
V1SubscriptionManager.notifyV1OfFeaturesChange user_id, (error) ->
if error?
logger.err {err: error, user_id}, "error notifying v1 about updated features"
jobs =
individualFeatures: (cb) -> FeaturesUpdater._getIndividualFeatures user_id, cb
groupFeatureSets: (cb) -> FeaturesUpdater._getGroupFeatureSets user_id, cb

View file

@ -10,6 +10,7 @@ GeoIpLookup = require("../../infrastructure/GeoIpLookup")
SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
UserGetter = require "../User/UserGetter"
FeaturesUpdater = require './FeaturesUpdater'
planFeatures = require './planFeatures'
module.exports = SubscriptionController =
@ -20,6 +21,7 @@ module.exports = SubscriptionController =
viewName = "#{viewName}_#{req.query.v}"
logger.log viewName:viewName, "showing plans page"
currentUser = null
GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency)->
return next(err) if err?
render = () ->
@ -29,6 +31,7 @@ module.exports = SubscriptionController =
gaExperiments: Settings.gaExperiments.plansPage
recomendedCurrency:recomendedCurrency
shouldABTestPlans: currentUser == null or (currentUser?.signUpDate? and currentUser.signUpDate >= (new Date('2016-10-27')))
planFeatures: planFeatures
user_id = AuthenticationController.getLoggedInUserId(req)
if user_id?
UserGetter.getUser user_id, {signUpDate: 1}, (err, user) ->

View file

@ -2,8 +2,8 @@ async = require("async")
_ = require("underscore")
SubscriptionUpdater = require("./SubscriptionUpdater")
SubscriptionLocator = require("./SubscriptionLocator")
UserGetter = require("../User/UserGetter")
Subscription = require("../../models/Subscription").Subscription
UserLocator = require("../User/UserLocator")
LimitationsManager = require("./LimitationsManager")
logger = require("logger-sharelatex")
OneTimeTokenHandler = require("../Security/OneTimeTokenHandler")
@ -22,7 +22,7 @@ module.exports = SubscriptionGroupHandler =
if limitReached
logger.err adminUserId:adminUserId, newEmail:newEmail, "group subscription limit reached not adding user to group"
return callback(limitReached:limitReached)
UserLocator.findByEmail newEmail, (err, user)->
UserGetter.getUserByMainEmail newEmail, (err, user)->
return callback(err) if err?
if user?
SubscriptionUpdater.addUserToGroup adminUserId, user._id, (err)->
@ -66,7 +66,7 @@ module.exports = SubscriptionGroupHandler =
users.push buildEmailInviteViewModel(email)
jobs = _.map subscription.member_ids, (user_id)->
return (cb)->
UserLocator.findById user_id, (err, user)->
UserGetter.getUser user_id, (err, user)->
if err? or !user?
users.push _id:user_id
return cb()

View file

@ -12,14 +12,11 @@ module.exports = V1SubscriptionManager =
# - 'v1_free'
getPlanCodeFromV1: (userId, callback=(err, planCode)->) ->
logger.log {userId}, "[V1SubscriptionManager] fetching v1 plan for user"
UserGetter.getUser userId, {'overleaf.id': 1}, (err, user) ->
return callback(err) if err?
v1Id = user?.overleaf?.id
if !v1Id?
logger.log {userId}, "[V1SubscriptionManager] no v1 id found for user"
return callback(null, null)
V1SubscriptionManager._v1PlanRequest v1Id, (err, body) ->
return callback(err) if err?
V1SubscriptionManager._v1Request userId, {
method: 'GET',
url: (v1Id) -> "/api/v1/sharelatex/users/#{v1Id}/plan_code"
}, (error, body) ->
return callback(error) if error?
planName = body?.plan_name
logger.log {userId, planName, body}, "[V1SubscriptionManager] fetched v1 plan for user"
if planName in ['pro', 'pro_plus', 'student', 'free']
@ -29,13 +26,25 @@ module.exports = V1SubscriptionManager =
planName = null
return callback(null, planName)
_v1PlanRequest: (v1Id, callback=(err, body)->) ->
notifyV1OfFeaturesChange: (userId, callback = (error) ->) ->
V1SubscriptionManager._v1Request userId, {
method: 'POST',
url: (v1Id) -> "/api/v1/sharelatex/users/#{v1Id}/sync"
}, callback
_v1Request: (userId, options, callback=(err, body)->) ->
if !settings?.apis?.v1
return callback null, null
UserGetter.getUser userId, {'overleaf.id': 1}, (err, user) ->
return callback(err) if err?
v1Id = user?.overleaf?.id
if !v1Id?
logger.log {userId}, "[V1SubscriptionManager] no v1 id found for user"
return callback(null, null)
request {
method: 'GET',
url: settings.apis.v1.url +
"/api/v1/sharelatex/users/#{v1Id}/plan_code"
baseUrl: settings.apis.v1.url
url: options.url(v1Id)
method: options.method
auth:
user: settings.apis.v1.user
pass: settings.apis.v1.pass
@ -48,3 +57,4 @@ module.exports = V1SubscriptionManager =
return callback null, body
else
return callback new Error("non-success code from v1: #{response.statusCode}")

View file

@ -0,0 +1,133 @@
module.exports =
[
{
feature: 'number_collab'
value: 'str'
plans: {
free: '1'
coll: '10'
prof: 'unlimited'
}
student: '6'
}
{
feature: 'unlimited_private'
value: 'bool'
info: 'unlimited_private_info'
plans: {
free: true
coll: true
prof: true
},
student: true
}
{
feature: 'realtime_collab'
value: 'bool'
info: 'realtime_collab_info'
plans: {
free: true
coll: true
prof: true
}
student: true
}
{
feature: 'hundreds_templates'
value: 'bool'
info: 'hundreds_templates_info'
plans: {
free: true
coll: true
prof: true
}
student: true
}
{
feature: 'powerful_latex_editor'
value: 'bool'
info: 'latex_editor_info'
plans: {
free: true
coll: true
prof: true
}
student: true
}
{
feature: 'realtime_track_changes'
value: 'bool'
info: 'realtime_track_changes_info'
plans: {
free: false
coll: true
prof: true
},
student: true
}
{
feature: 'reference_search'
value: 'bool'
info: 'reference_search_info'
plans: {
free: false
coll: true
prof: true
},
student: true
},
{
feature: 'reference_sync'
info: 'reference_sync_info'
value: 'bool'
plans: {
free: false
coll: true
prof: true
},
student: true
}
{
feature: 'full_doc_history'
value: 'bool'
info: 'full_doc_history_info'
plans: {
free: false,
coll: true,
prof: true
},
student: true
}
{
feature: 'dropbox_integration_lowercase'
value: 'bool'
info: 'dropbox_integration_info'
plans: {
free: false,
coll: true,
prof: true
},
student: true
},
{
feature: 'github_integration_lowercase'
value: 'bool'
info: 'github_integration_info'
plans: {
free: false,
coll: true,
prof: true
},
student: true
},
{
feature: 'priority_support',
value: 'bool',
plans: {
free: false,
coll: true,
prof: true
},
student: true
},
]

View file

@ -0,0 +1,80 @@
path = require('path')
Project = require('../../../js/models/Project').Project
ProjectUploadManager = require('../../../js/Features/Uploads/ProjectUploadManager')
ProjectOptionsHandler = require('../../../js/Features/Project/ProjectOptionsHandler')
AuthenticationController = require('../../../js/Features/Authentication/AuthenticationController')
settings = require('settings-sharelatex')
fs = require('fs')
request = require('request')
uuid = require('uuid')
logger = require('logger-sharelatex')
async = require("async")
module.exports = TemplatesController =
getV1Template: (req, res)->
templateVersionId = req.params.Template_version_id
templateId = req.query.id
if !/^[0-9]+$/.test(templateVersionId) || !/^[0-9]+$/.test(templateId)
logger.err templateVersionId:templateVersionId, templateId: templateId, "invalid template id or version"
return res.sendStatus 400
data = {}
data.templateVersionId = templateVersionId
data.templateId = templateId
data.name = req.query.templateName
data.compiler = req.query.latexEngine
res.render path.resolve(__dirname, "../../../views/project/editor/new_from_template"), data
createProjectFromV1Template: (req, res)->
currentUserId = AuthenticationController.getLoggedInUserId(req)
zipUrl = "#{settings.apis.v1.url}/api/v1/sharelatex/templates/#{req.body.templateVersionId}"
zipReq = request(zipUrl, {
'auth': {
'user': settings.apis.v1.user,
'pass': settings.apis.v1.pass
}
})
TemplatesController.createFromZip(
zipReq,
{
templateName: req.body.templateName,
currentUserId: currentUserId,
compiler: req.body.compiler
docId: req.body.docId
templateId: req.body.templateId
templateVersionId: req.body.templateVersionId
},
req,
res
)
createFromZip: (zipReq, options, req, res)->
dumpPath = "#{settings.path.dumpFolder}/#{uuid.v4()}"
writeStream = fs.createWriteStream(dumpPath)
zipReq.on "error", (error) ->
logger.error err: error, "error getting zip from template API"
zipReq.pipe(writeStream)
writeStream.on 'close', ->
ProjectUploadManager.createProjectFromZipArchive options.currentUserId, options.templateName, dumpPath, (err, project)->
if err?
logger.err err:err, zipReq:zipReq, "problem building project from zip"
return res.sendStatus 500
setCompiler project._id, options.compiler, ->
fs.unlink dumpPath, ->
delete req.session.templateData
conditions = {_id:project._id}
update = {
fromV1TemplateId:options.templateId,
fromV1TemplateVersionId:options.templateVersionId
}
Project.update conditions, update, {}, (err)->
res.redirect "/project/#{project._id}"
setCompiler = (project_id, compiler, callback)->
if compiler?
ProjectOptionsHandler.setCompiler project_id, compiler, callback
else
callback()

View file

@ -0,0 +1,8 @@
settings = require("settings-sharelatex")
logger = require("logger-sharelatex")
module.exports =
saveTemplateDataInSession: (req, res, next)->
if req.query.templateName
req.session.templateData = req.query
next()

View file

@ -0,0 +1,9 @@
settings = require("settings-sharelatex")
logger = require("logger-sharelatex")
module.exports =
saveTemplateDataInSession: (req, res, next)->
if req.query.templateName
req.session.templateData = req.query
next()

View file

@ -0,0 +1,10 @@
AuthenticationController = require('../Authentication/AuthenticationController')
TemplatesController = require("./TemplatesController")
TemplatesMiddlewear = require('./TemplatesMiddlewear')
module.exports =
apply: (app)->
app.get '/project/new/template/:Template_version_id', TemplatesMiddlewear.saveTemplateDataInSession, AuthenticationController.requireLogin(), TemplatesController.getV1Template
app.post '/project/new/template', AuthenticationController.requireLogin(), TemplatesController.createProjectFromV1Template

View file

@ -1,6 +1,6 @@
UserHandler = require("./UserHandler")
UserDeleter = require("./UserDeleter")
UserLocator = require("./UserLocator")
UserGetter = require("./UserGetter")
User = require("../../models/User").User
newsLetterManager = require('../Newsletter/NewsletterManager')
UserRegistrationHandler = require("./UserRegistrationHandler")
@ -45,7 +45,7 @@ module.exports = UserController =
unsubscribe: (req, res)->
user_id = AuthenticationController.getLoggedInUserId(req)
UserLocator.findById user_id, (err, user)->
UserGetter.getUser user_id, (err, user)->
newsLetterManager.unsubscribe user, ->
res.send()

View file

@ -1,19 +1,10 @@
User = require("../../models/User").User
UserLocator = require("./UserLocator")
logger = require("logger-sharelatex")
metrics = require('metrics-sharelatex')
module.exports = UserCreator =
getUserOrCreateHoldingAccount: (email, callback = (err, user)->)->
self = @
UserLocator.findByEmail email, (err, user)->
if user?
callback(err, user)
else
self.createNewUser email:email, holdingAccount:true, callback
createNewUser: (opts, callback)->
logger.log opts:opts, "creating new user"
user = new User()

View file

@ -6,6 +6,8 @@ ObjectId = mongojs.ObjectId
module.exports = UserGetter =
getUser: (query, projection, callback = (error, user) ->) ->
if query?.email?
return callback(new Error("Don't use getUser to find user by email"), null)
if arguments.length == 2
callback = projection
projection = {}
@ -19,6 +21,31 @@ module.exports = UserGetter =
db.users.findOne query, projection, callback
getUserEmail: (userId, callback = (error, email) ->) ->
@getUser userId, { email: 1 }, (error, user) ->
callback(error, user?.email)
getUserByMainEmail: (email, projection, callback = (error, user) ->) ->
email = email.trim()
if arguments.length == 2
callback = projection
projection = {}
db.users.findOne email: email, projection, callback
getUserByAnyEmail: (email, projection, callback = (error, user) ->) ->
email = email.trim()
if arguments.length == 2
callback = projection
projection = {}
# $exists: true MUST be set to use the partial index
query = emails: { $exists: true }, 'emails.email': email
db.users.findOne query, projection, (error, user) =>
return callback(error, user) if error? or user?
# While multiple emails are being rolled out, check for the main email as
# well
@getUserByMainEmail email, projection, callback
getUsers: (user_ids, projection, callback = (error, users) ->) ->
try
user_ids = user_ids.map (u) -> ObjectId(u.toString())
@ -39,6 +66,9 @@ module.exports = UserGetter =
[
'getUser',
'getUserEmail',
'getUserByMainEmail',
'getUserByAnyEmail',
'getUsers',
'getUserOrUserStubById'
].map (method) ->

View file

@ -1,21 +0,0 @@
mongojs = require("../../infrastructure/mongojs")
metrics = require("metrics-sharelatex")
db = mongojs.db
ObjectId = mongojs.ObjectId
logger = require('logger-sharelatex')
module.exports = UserLocator =
findByEmail: (email, callback)->
email = email.trim()
db.users.findOne email:email, (err, user)->
callback(err, user)
findById: (_id, callback)->
db.users.findOne _id:ObjectId(_id+""), callback
[
'findById',
'findByEmail'
].map (method) ->
metrics.timeAsyncMethod UserLocator, method, 'mongo.UserLocator', logger

View file

@ -1,4 +1,3 @@
UserLocator = require("./UserLocator")
UserGetter = require("./UserGetter")
UserSessionsManager = require("./UserSessionsManager")
ErrorController = require("../Errors/ErrorController")
@ -61,7 +60,7 @@ module.exports =
user_id = AuthenticationController.getLoggedInUserId(req)
logger.log user: user_id, "loading settings page"
shouldAllowEditingDetails = !(Settings?.ldap?.updateUserDetailsOnLogin) and !(Settings?.saml?.updateUserDetailsOnLogin)
UserLocator.findById user_id, (err, user)->
UserGetter.getUser user_id, (err, user)->
return next(err) if err?
res.render 'user/settings',
title:'account_settings'

View file

@ -1,6 +1,7 @@
sanitize = require('sanitizer')
User = require("../../models/User").User
UserCreator = require("./UserCreator")
UserGetter = require("./UserGetter")
AuthenticationManager = require("../Authentication/AuthenticationManager")
NewsLetterManager = require("../Newsletter/NewsletterManager")
async = require("async")
@ -47,7 +48,7 @@ module.exports = UserRegistrationHandler =
if !requestIsValid
return callback(new Error("request is not valid"))
userDetails.email = userDetails.email?.trim()?.toLowerCase()
User.findOne email:userDetails.email, (err, user)->
UserGetter.getUserByMainEmail userDetails.email, (err, user) =>
if err?
return callback err
if user?.holdingAccount == false

View file

@ -2,8 +2,9 @@ logger = require("logger-sharelatex")
mongojs = require("../../infrastructure/mongojs")
metrics = require("metrics-sharelatex")
db = mongojs.db
async = require("async")
ObjectId = mongojs.ObjectId
UserLocator = require("./UserLocator")
UserGetter = require("./UserGetter")
module.exports = UserUpdater =
updateUser: (query, update, callback = (error) ->) ->
@ -11,23 +12,89 @@ module.exports = UserUpdater =
query = _id: ObjectId(query)
else if query instanceof ObjectId
query = _id: query
else if typeof query._id == "string"
query._id = ObjectId(query._id)
db.users.update query, update, callback
changeEmailAddress: (user_id, newEmail, callback)->
self = @
logger.log user_id:user_id, newEmail:newEmail, "updaing email address of user"
UserLocator.findByEmail newEmail, (error, user) ->
if user?
return callback({message:"alread_exists"})
self.updateUser user_id.toString(), {
$set: { "email": newEmail},
}, (err) ->
if err?
logger.err err:err, "problem updating users email"
return callback(err)
#
# DEPRECATED
#
# Change the user's main email address by adding a new email, switching the
# default email and removing the old email. Prefer manipulating multiple
# emails and the default rather than calling this method directly
#
changeEmailAddress: (userId, newEmail, callback)->
logger.log userId: userId, newEmail: newEmail, "updaing email address of user"
oldEmail = null
async.series [
(cb) ->
UserGetter.getUserEmail userId, (error, email) ->
oldEmail = email
cb(error)
(cb) -> UserUpdater.addEmailAddress userId, newEmail, cb
(cb) -> UserUpdater.setDefaultEmailAddress userId, newEmail, cb
(cb) -> UserUpdater.removeEmailAddress userId, oldEmail, cb
], callback
# Add a new email address for the user. Email cannot be already used by this
# or any other user
addEmailAddress: (userId, newEmail, callback) ->
@_ensureUniqueEmailAddress newEmail, (error) =>
return callback(error) if error?
update = $push: emails: email: newEmail, createdAt: new Date()
@updateUser userId, update, (error) ->
if error?
logger.err error: error, 'problem updating users emails'
return callback(error)
callback()
metrics.timeAsyncMethod UserUpdater, 'updateUser', 'mongo.UserUpdater', logger
# remove one of the user's email addresses. The email cannot be the user's
# default email address
removeEmailAddress: (userId, email, callback) ->
query = _id: userId, email: $ne: email
update = $pull: emails: email: email
@updateUser query, update, (error, res) ->
if error?
logger.err error:error, 'problem removing users email'
return callback(error)
if res.nMatched == 0
return callback(new Error('Cannot remove default email'))
callback()
# set the default email address by setting the `email` attribute. The email
# must be one of the user's multiple emails (`emails` attribute)
setDefaultEmailAddress: (userId, email, callback) ->
query = _id: userId, 'emails.email': email
update = $set: email: email
@updateUser query, update, (error, res) ->
if error?
logger.err error:error, 'problem setting default emails'
return callback(error)
if res.nMatched == 0
return callback(new Error('Default email does not belong to user'))
callback()
# check for duplicate email address. This is also enforced at the DB level
_ensureUniqueEmailAddress: (newEmail, callback) ->
UserGetter.getUserByAnyEmail newEmail, (error, user) ->
return callback(message: 'alread_exists') if user?
callback()
[
'updateUser'
'changeEmailAddress'
'setDefaultEmailAddress'
'addEmailAddress'
'removeEmailAddress'
'_ensureUniqueEmailAddress'
].map (method) ->
metrics.timeAsyncMethod(UserUpdater, method, 'mongo.UserUpdater', logger)

View file

@ -6,16 +6,31 @@ Settings = require 'settings-sharelatex'
request = require 'request'
module.exports = FileWriter =
_ensureDumpFolderExists: (callback=(error)->) ->
fs.mkdir Settings.path.dumpFolder, (error) ->
if error? and error.code != 'EEXIST'
# Ignore error about already existing
return callback(error)
callback(null)
writeLinesToDisk: (identifier, lines, callback = (error, fsPath)->) ->
callback = _.once(callback)
fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}"
FileWriter._ensureDumpFolderExists (error) ->
return callback(error) if error?
fs.writeFile fsPath, lines.join('\n'), (error) ->
return callback(error) if error?
callback(null, fsPath)
writeStreamToDisk: (identifier, stream, callback = (error, fsPath) ->) ->
callback = _.once(callback)
fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}"
stream.pause()
fs.mkdir Settings.path.dumpFolder, (error) ->
FileWriter._ensureDumpFolderExists (error) ->
return callback(error) if error?
stream.resume()
if error? and error.code != 'EEXIST'
# Ignore error about already existing
return callback(error)
writeStream = fs.createWriteStream(fsPath)
stream.pipe(writeStream)

View file

@ -30,7 +30,8 @@ module.exports = Modules =
for module in @modules
for view, partial of module.viewIncludes or {}
@viewIncludes[view] ||= []
@viewIncludes[view].push pug.compile(fs.readFileSync(Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".pug")), doctype: "html")
filePath = Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".pug")
@viewIncludes[view].push pug.compileFile(filePath, doctype: "html")
moduleIncludes: (view, locals) ->
compiledPartials = Modules.viewIncludes[view] or []

View file

@ -48,6 +48,7 @@ MetaController = require('./Features/Metadata/MetaController')
TokenAccessController = require('./Features/TokenAccess/TokenAccessController')
Features = require('./infrastructure/Features')
LinkedFilesRouter = require './Features/LinkedFiles/LinkedFilesRouter'
TemplatesRouter = require './Features/Templates/TemplatesRouter'
logger = require("logger-sharelatex")
_ = require("underscore")
@ -80,10 +81,10 @@ module.exports = class Router
ContactRouter.apply(webRouter, privateApiRouter)
AnalyticsRouter.apply(webRouter, privateApiRouter, publicApiRouter)
LinkedFilesRouter.apply(webRouter, privateApiRouter, publicApiRouter)
TemplatesRouter.apply(webRouter)
Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter)
if Settings.enableSubscriptions
webRouter.get '/user/bonus', AuthenticationController.requireLogin(), ReferalController.bonus
@ -119,6 +120,11 @@ module.exports = class Router
webRouter.get '/user/personal_info', AuthenticationController.requireLogin(), UserInfoController.getLoggedInUsersPersonalInfo
privateApiRouter.get '/user/:user_id/personal_info', AuthenticationController.httpAuth, UserInfoController.getPersonalInfo
webRouter.get '/user/projects', AuthenticationController.requireLogin(), ProjectController.userProjectsJson
webRouter.get '/project/:Project_id/entities', AuthenticationController.requireLogin(),
AuthorizationMiddlewear.ensureUserCanReadProject,
ProjectController.projectEntitiesJson
webRouter.get '/project', AuthenticationController.requireLogin(), ProjectController.projectListPage
webRouter.post '/project/new', AuthenticationController.requireLogin(), ProjectController.newProject
@ -201,7 +207,8 @@ module.exports = class Router
webRouter.get "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails
webRouter.get "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
webRouter.get "/project/:Project_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails
webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
webRouter.get "/project/:Project_id/filetree/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
webRouter.post '/project/:project_id/doc/:doc_id/restore', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreDocFromDeletedDoc
webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2
privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory

View file

@ -1,67 +0,0 @@
script(type='text/ng-template', id='supportModalTemplate')
.modal-header
button.close(
type="button"
data-dismiss="modal"
ng-click="close()"
) ×
h3 #{translate("contact_us")}
.modal-body.contact-us-modal
form(name="contactForm")
span(ng-show="sent == false")
.alert.alert-danger(ng-show="error") Something went wrong sending your request :(
label
| #{translate("subject")}
.form-group
input.field.text.medium.span8.form-control(
name="subject",
required
ng-model="form.subject",
ng-model-options="{ updateOn: 'default blur', debounce: {'default': 350, 'blur': 0} }"
maxlength='255',
tabindex='1',
onkeyup='')
.contact-suggestions(ng-show="suggestions.length")
p.contact-suggestion-label !{translate("kb_suggestions_enquiry", { kbLink: "<a href='learn/kb' target='_blank'>" + translate("knowledge_base") + "</a>" })}
ul.contact-suggestion-list
li(ng-repeat="suggestion in suggestions")
a.contact-suggestion-list-item(ng-href="{{ suggestion.url }}", ng-click="clickSuggestionLink(suggestion.url);" target="_blank")
span(ng-bind-html="suggestion.name")
i.fa.fa-angle-right
label.desc(ng-show="'"+getUserEmail()+"'.length < 1")
| #{translate("email")}
.form-group(ng-show="'"+getUserEmail()+"'.length < 1")
input.field.text.medium.span8.form-control(
name="email",
required
ng-model="form.email",
ng-init="form.email = '"+getUserEmail()+"'",
type='email', spellcheck='false',
value='',
maxlength='255',
tabindex='2')
label#title12.desc
| #{translate("project_url")} (#{translate("optional")})
.form-group
input.field.text.medium.span8.form-control(ng-model="form.project_url", tabindex='3', onkeyup='')
label.desc
| #{translate("contact_message_label")}
.form-group
textarea.field.text.medium.span8.form-control(
name="body",
required
ng-model="form.message",
type='text',
value='',
tabindex='4',
onkeyup=''
)
.form-group.text-center
input.btn-success.btn.btn-lg(
type='submit',
ng-disabled="contactForm.$invalid || sending",
ng-click="contactUs()"
value=translate("contact_us")
)
span(ng-show="sent")
p #{translate("request_sent_thank_you")}

View file

@ -147,7 +147,7 @@ html(itemscope, itemtype='http://schema.org/Product')
src=buildJsPath('libs/require.js', {hashedPath:true})
)
include contact-us-modal
!= moduleIncludes("contactModal", locals)
include v1-tooltip
include sentry

View file

@ -57,9 +57,12 @@ block content
include ./editor/share
!= moduleIncludes("publish:body", locals)
include ./editor/history/toolbarV2.pug
main#ide-body(
ng-cloak,
role="main",
ng-class="{ 'ide-history-open' : (ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME) }",
layout="main",
ng-hide="state.loading",
resize-on="layout:chat:resize",
@ -70,7 +73,7 @@ block content
)
.ui-layout-west
include ./editor/file-tree
include ./editor/history-file-tree
include ./editor/history/fileTreeV2
.ui-layout-center
include ./editor/editor

View file

@ -47,7 +47,18 @@ div.binary-file.full-size(
|
| at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }}
span(ng-if="openFile.linkedFileData.provider == 'url'")
div(ng-if="openFile.linkedFileData.provider == 'project_file'")
p
i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon
| Imported from
|
a(ng-href='/project/{{openFile.linkedFileData.source_project_id}}' target="_blank")
| {{ openFile.linkedFileData.source_project_display_name }}
| /{{ openFile.linkedFileData.source_entity_path.slice(1) }},
|
| at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }}
span(ng-if="openFile.linkedFileData.provider == 'url' || openFile.linkedFileData.provider == 'project_file'")
button.btn.btn-success(
href, ng-click="refreshFile(openFile)",
ng-disabled="refreshing"
@ -63,3 +74,7 @@ div.binary-file.full-size(
i.fa.fa-fw.fa-download
|
| #{translate("download")}
div(ng-if="refreshError")
br
.alert.alert-danger.col-md-6.col-md-offset-3
| Error: {{ refreshError}}

View file

@ -33,11 +33,11 @@ div.full-size(
i.fa.fa-arrow-left
| &nbsp;&nbsp;#{translate("open_a_file_on_the_left")}
!= moduleIncludes('editor:toolbar', locals)
!= moduleIncludes('editor:main', locals)
#editor(
ace-editor="editor",
ng-if="!editor.richText",
ng-if="!editor.showRichText",
ng-show="!!editor.sharejs_doc && !editor.opening",
style=showRichText ? "top: 32px" : "",
theme="settings.theme",
@ -73,8 +73,6 @@ div.full-size(
line-height="settings.lineHeight || ui.defaultLineHeight"
)
!= moduleIncludes('editor:body', locals)
include ./review-panel
.ui-layout-east

View file

@ -342,6 +342,77 @@ script(type='text/ng-template', id='newDocModalTemplate')
span(ng-show="state.inflight") #{translate("creating")}...
// Project Linked Files Modal
script(type='text/ng-template', id='projectLinkedFileModalTemplate')
.modal-header
h3 New file from Project
.modal-body
div
div.alert.alert-danger(ng-if="state.error") Error, something went wrong!
div
form
.form-controls
label(for="project-select") Select a Project
span(ng-show="state.inFlight.projects")
| &nbsp;
i.fa.fa-spinner.fa-spin
select.form-control(
name="project-select"
ng-model="data.selectedProjectId"
ng-disabled="!shouldEnableProjectSelect()"
)
option(value="" disabled selected) - Please Select a Project
option(
ng-repeat="project in data.projects"
value="{{ project._id }}"
) {{ project.name }}
br
.form-controls
label(for="project-entity-select") Select a File
span(ng-show="state.inFlight.entities")
| &nbsp;
i.fa.fa-spinner.fa-spin
select.form-control(
name="project-entity-select"
ng-model="data.selectedProjectEntity"
ng-disabled="!shouldEnableProjectEntitySelect()"
)
option(value="" disabled selected) - Please Select a File
option(
ng-repeat="projectEntity in data.projectEntities"
value="{{ projectEntity.path }}"
) {{ projectEntity.path.slice(1) }}
br
.form-controls
label(for="name") File Name In This Project
input.form-control(
type="text"
placeholder="example.tex"
required
ng-model="data.name"
name="name"
)
br
.modal-footer
span(ng-show="state.inFlight.create")
i.fa.fa-spinner.fa-spin
| &nbsp;
button.btn.btn-default(
ng-disabled="state.inflight"
ng-click="cancel()"
) #{translate("cancel")}
button.btn.btn-primary(
ng-disabled="!shouldEnableCreateButton()"
ng-click="create()"
)
span(ng-hide="state.inflight") #{translate("create")}
span(ng-show="state.inflight") #{translate("creating")}...
script(type='text/ng-template', id='linkedFileModalTemplate')
.modal-header
h3 New file from URL

View file

@ -1,17 +0,0 @@
aside.file-tree.file-tree-history(ng-controller="FileTreeController", ng-class="{ 'multi-selected': multiSelectedCount > 0 }", ng-show="ui.view == 'history' && history.isV2").full-size
.toolbar.toolbar-filetree
span Modified files
.file-tree-inner
ul.list-unstyled.file-tree-list
li(
ng-repeat="(pathname, doc) in history.selection.docs"
ng-class="{ 'selected': history.selection.pathname == pathname }"
)
.entity
.entity-name.entity-name-history(
ng-click="history.selection.pathname = pathname",
ng-class="{ 'deleted': !!doc.deletedAtV }"
)
i.fa.fa-fw.fa-pencil
span {{ pathname }}

View file

@ -40,90 +40,11 @@ div#history(ng-show="ui.view == 'history'")
p
a.small(href, ng-click="toggleHistory()") #{translate("cancel")}
aside.change-list(
ng-controller="HistoryListController"
infinite-scroll="loadMore()"
infinite-scroll-disabled="history.loading || history.atEnd"
infinite-scroll-initialize="ui.view == 'history'"
)
.infinite-scroll-inner
ul.list-unstyled(
ng-class="{\
'hover-state': history.hoveringOverListSelectors\
}"
)
li.change(
ng-repeat="update in history.updates"
ng-class="{\
'first-in-day': update.meta.first_in_day,\
'selected': update.inSelection,\
'selected-to': update.selectedTo,\
'selected-from': update.selectedFrom,\
'hover-selected': update.inHoverSelection,\
'hover-selected-to': update.hoverSelectedTo,\
'hover-selected-from': update.hoverSelectedFrom,\
}"
ng-controller="HistoryListItemController"
)
div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }}
div.selectors
div.range
form
input.selector-from(
type="radio"
name="fromVersion"
ng-model="update.selectedFrom"
ng-value="true"
ng-mouseover="mouseOverSelectedFrom()"
ng-mouseout="mouseOutSelectedFrom()"
ng-show="update.afterSelection || update.inSelection"
)
form
input.selector-to(
type="radio"
name="toVersion"
ng-model="update.selectedTo"
ng-value="true"
ng-mouseover="mouseOverSelectedTo()"
ng-mouseout="mouseOutSelectedTo()"
ng-show="update.beforeSelection || update.inSelection"
)
div.description(ng-click="select()")
div.time {{ update.meta.end_ts | formatDate:'h:mm a' }}
div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0")
| Edited
div.docs(ng-repeat="pathname in update.pathnames")
.doc {{ pathname }}
div.docs(ng-repeat="project_op in update.project_ops")
div(ng-if="project_op.rename")
.action Renamed
.doc {{ project_op.rename.pathname }} &rarr; {{ project_op.rename.newPathname }}
div(ng-if="project_op.add")
.action Created
.doc {{ project_op.add.pathname }}
div(ng-if="project_op.remove")
.action Deleted
.doc {{ project_op.remove.pathname }}
div.users
div.user(ng-repeat="update_user in update.meta.users")
.color-square(ng-if="update_user != null", ng-style="{'background-color': 'hsl({{ update_user.hue }}, 70%, 50%)'}")
.color-square(ng-if="update_user == null", ng-style="{'background-color': 'hsl(100, 70%, 50%)'}")
.name(ng-if="update_user && update_user.id != user.id" ng-bind="displayName(update_user)")
.name(ng-if="update_user && update_user.id == user.id") You
.name(ng-if="update_user == null") #{translate("anonymous")}
div.user(ng-if="update.meta.users.length == 0")
.color-square(style="background-color: hsl(100, 100%, 50%)")
span #{translate("anonymous")}
.loading(ng-show="history.loading")
i.fa.fa-spin.fa-refresh
| &nbsp;&nbsp; #{translate("loading")}...
include ./history/entriesListV1
include ./history/entriesListV2
include ./history/diffPanelV1
include ./history/diffPanelV2
include ./history/previewPanelV2
script(type="text/ng-template", id="historyRestoreDiffModalTemplate")
.modal-header

View file

@ -13,8 +13,8 @@
}"
)
| in <strong>{{history.diff.pathname}}</strong>
.toolbar-right
a.btn.btn-danger.btn-sm(
.toolbar-right(ng-if="permissions.write")
a.btn.btn-danger.btn-xs(
href,
ng-click="openRestoreDiffModal()"
) #{translate("restore_to_before_these_changes")}

View file

@ -0,0 +1,82 @@
aside.change-list(
ng-if="!history.isV2"
ng-controller="HistoryListController"
infinite-scroll="loadMore()"
infinite-scroll-disabled="history.loading || history.atEnd"
infinite-scroll-initialize="ui.view == 'history'"
)
.infinite-scroll-inner
ul.list-unstyled(
ng-class="{\
'hover-state': history.hoveringOverListSelectors\
}"
)
li.change(
ng-repeat="update in history.updates"
ng-class="{\
'first-in-day': update.meta.first_in_day,\
'selected': update.inSelection,\
'selected-to': update.selectedTo,\
'selected-from': update.selectedFrom,\
'hover-selected': update.inHoverSelection,\
'hover-selected-to': update.hoverSelectedTo,\
'hover-selected-from': update.hoverSelectedFrom,\
}"
ng-controller="HistoryListItemController"
)
div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }}
div.selectors
div.range
form
input.selector-from(
type="radio"
name="fromVersion"
ng-model="update.selectedFrom"
ng-value="true"
ng-mouseover="mouseOverSelectedFrom()"
ng-mouseout="mouseOutSelectedFrom()"
ng-show="update.afterSelection || update.inSelection"
)
form
input.selector-to(
type="radio"
name="toVersion"
ng-model="update.selectedTo"
ng-value="true"
ng-mouseover="mouseOverSelectedTo()"
ng-mouseout="mouseOutSelectedTo()"
ng-show="update.beforeSelection || update.inSelection"
)
div.description(ng-click="select()")
div.time {{ update.meta.end_ts | formatDate:'h:mm a' }}
div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0")
| #{translate("file_action_edited")}
div.docs(ng-repeat="pathname in update.pathnames")
.doc {{ pathname }}
div.docs(ng-repeat="project_op in update.project_ops")
div(ng-if="project_op.rename")
.action #{translate("file_action_renamed")}
.doc {{ project_op.rename.pathname }} &rarr; {{ project_op.rename.newPathname }}
div(ng-if="project_op.add")
.action #{translate("file_action_created")}
.doc {{ project_op.add.pathname }}
div(ng-if="project_op.remove")
.action #{translate("file_action_deleted")}
.doc {{ project_op.remove.pathname }}
div.users
div.user(ng-repeat="update_user in update.meta.users")
.color-square(ng-if="update_user != null", ng-style="{'background-color': 'hsl({{ update_user.hue }}, 70%, 50%)'}")
.color-square(ng-if="update_user == null", ng-style="{'background-color': 'hsl(100, 70%, 50%)'}")
.name(ng-if="update_user && update_user.id != user.id" ng-bind="displayName(update_user)")
.name(ng-if="update_user && update_user.id == user.id") You
.name(ng-if="update_user == null") #{translate("anonymous")}
div.user(ng-if="update.meta.users.length == 0")
.color-square(style="background-color: hsl(100, 100%, 50%)")
span #{translate("anonymous")}
.loading(ng-show="history.loading")
i.fa.fa-spin.fa-refresh
| &nbsp;&nbsp; #{translate("loading")}...

View file

@ -0,0 +1,174 @@
aside.change-list(
ng-if="history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME"
ng-controller="HistoryV2ListController"
)
history-entries-list(
entries="history.updates"
current-user="user"
load-entries="loadMore()"
load-disabled="history.loading || history.atEnd"
load-initialize="ui.view == 'history'"
is-loading="history.loading"
on-entry-select="handleEntrySelect(selectedEntry)"
)
aside.change-list(
ng-if="history.isV2 && history.viewMode === HistoryViewModes.COMPARE"
ng-controller="HistoryListController"
infinite-scroll="loadMore()"
infinite-scroll-disabled="history.loading || history.atEnd"
infinite-scroll-initialize="ui.view == 'history'"
)
.infinite-scroll-inner
ul.list-unstyled(
ng-class="{\
'hover-state': history.hoveringOverListSelectors\
}"
)
li.change(
ng-repeat="update in history.updates"
ng-class="{\
'first-in-day': update.meta.first_in_day,\
'selected': update.inSelection,\
'selected-to': update.selectedTo,\
'selected-from': update.selectedFrom,\
'hover-selected': update.inHoverSelection,\
'hover-selected-to': update.hoverSelectedTo,\
'hover-selected-from': update.hoverSelectedFrom,\
}"
ng-controller="HistoryListItemController"
)
div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }}
div.selectors
div.range
form
input.selector-from(
type="radio"
name="fromVersion"
ng-model="update.selectedFrom"
ng-value="true"
ng-mouseover="mouseOverSelectedFrom()"
ng-mouseout="mouseOutSelectedFrom()"
ng-show="update.afterSelection || update.inSelection"
)
form
input.selector-to(
type="radio"
name="toVersion"
ng-model="update.selectedTo"
ng-value="true"
ng-mouseover="mouseOverSelectedTo()"
ng-mouseout="mouseOutSelectedTo()"
ng-show="update.beforeSelection || update.inSelection"
)
div.description(ng-click="select()")
div.time {{ update.meta.end_ts | formatDate:'h:mm a' }}
div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0")
| #{translate("file_action_edited")}
div.docs(ng-repeat="pathname in update.pathnames")
.doc {{ pathname }}
div.docs(ng-repeat="project_op in update.project_ops")
div(ng-if="project_op.rename")
.action #{translate("file_action_renamed")}
.doc {{ project_op.rename.pathname }} &rarr; {{ project_op.rename.newPathname }}
div(ng-if="project_op.add")
.action #{translate("file_action_created")}
.doc {{ project_op.add.pathname }}
div(ng-if="project_op.remove")
.action #{translate("file_action_deleted")}
.doc {{ project_op.remove.pathname }}
div.users
div.user(ng-repeat="update_user in update.meta.users")
.color-square(ng-if="update_user != null", ng-style="{'background-color': 'hsl({{ update_user.hue }}, 70%, 50%)'}")
.color-square(ng-if="update_user == null", ng-style="{'background-color': 'hsl(100, 70%, 50%)'}")
.name(ng-if="update_user && update_user.id != user.id" ng-bind="displayName(update_user)")
.name(ng-if="update_user && update_user.id == user.id") You
.name(ng-if="update_user == null") #{translate("anonymous")}
div.user(ng-if="update.meta.users.length == 0")
.color-square(style="background-color: hsl(100, 100%, 50%)")
span #{translate("anonymous")}
.loading(ng-show="history.loading")
i.fa.fa-spin.fa-refresh
| &nbsp;&nbsp; #{translate("loading")}...
script(type="text/ng-template", id="historyEntriesListTpl")
.history-entries(
infinite-scroll="$ctrl.loadEntries()"
infinite-scroll-disabled="$ctrl.loadDisabled"
infinite-scroll-initialize="$ctrl.loadInitialize"
)
.infinite-scroll-inner
history-entry(
ng-repeat="entry in $ctrl.entries"
entry="entry"
current-user="$ctrl.currentUser"
on-select="$ctrl.onEntrySelect({ selectedEntry: selectedEntry })"
ng-show="!$ctrl.isLoading"
)
.loading(ng-show="$ctrl.isLoading")
i.fa.fa-spin.fa-refresh
| &nbsp;&nbsp; #{translate("loading")}...
script(type="text/ng-template", id="historyEntryTpl")
.history-entry(
ng-class="{\
'history-entry-first-in-day': $ctrl.entry.meta.first_in_day,\
'history-entry-selected': $ctrl.entry.inSelection,\
'history-entry-selected-to': $ctrl.entry.selectedTo,\
'history-entry-selected-from': $ctrl.entry.selectedFrom,\
'history-entry-hover-selected': $ctrl.entry.inHoverSelection,\
'history-entry-hover-selected-to': $ctrl.entry.hoverSelectedTo,\
'history-entry-hover-selected-from': $ctrl.entry.hoverSelectedFrom,\
}"
)
time.history-entry-day(ng-if="::$ctrl.entry.meta.first_in_day") {{ ::$ctrl.entry.meta.end_ts | relativeDate }}
.history-entry-details(ng-click="$ctrl.onSelect({ selectedEntry: $ctrl.entry })")
ol.history-entry-changes
li.history-entry-change(
ng-repeat="pathname in ::$ctrl.entry.pathnames"
)
span.history-entry-change-action #{translate("file_action_edited")}
span.history-entry-change-doc {{ ::pathname }}
li.history-entry-change(
ng-repeat="project_op in ::$ctrl.entry.project_ops"
)
span.history-entry-change-action(
ng-if="::project_op.rename"
) #{translate("file_action_renamed")}
span.history-entry-change-action(
ng-if="::project_op.add"
) #{translate("file_action_created")}
span.history-entry-change-action(
ng-if="::project_op.remove"
) #{translate("file_action_deleted")}
span.history-entry-change-doc {{ ::$ctrl.getProjectOpDoc(project_op) }}
.history-entry-metadata
time.history-entry-metadata-time {{ ::$ctrl.entry.meta.end_ts | formatDate:'h:mm a' }}
span
|
| &bull;
|
ol.history-entry-metadata-users
li.history-entry-metadata-user(ng-repeat="update_user in ::$ctrl.entry.meta.users")
span.name(
ng-if="::update_user && update_user.id != $ctrl.currentUser.id"
ng-style="$ctrl.getUserCSSStyle(update_user);"
) {{ ::$ctrl.displayName(update_user) }}
span.name(
ng-if="::update_user && update_user.id == $ctrl.currentUser.id"
ng-style="$ctrl.getUserCSSStyle(update_user);"
) You
span.name(
ng-if="::update_user == null"
ng-style="$ctrl.getUserCSSStyle(update_user);"
) #{translate("anonymous")}
li.history-entry-metadata-user(ng-if="::$ctrl.entry.meta.users.length == 0")
span.name(
ng-style="$ctrl.getUserCSSStyle();"
) #{translate("anonymous")}

View file

@ -0,0 +1,70 @@
aside.file-tree.full-size(
ng-controller="HistoryV2FileTreeController"
ng-if="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME"
)
.history-file-tree-inner
history-file-tree(
file-tree="currentFileTree"
selected-pathname="history.selection.pathname"
on-selected-file-change="handleFileSelection(file)"
is-loading="history.loadingFileTree"
)
aside.file-tree.file-tree-history.full-size(
ng-controller="FileTreeController"
ng-class="{ 'multi-selected': multiSelectedCount > 0 }"
ng-show="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.COMPARE")
.toolbar.toolbar-filetree
span Modified files
.file-tree-inner
ul.list-unstyled.file-tree-list
li(
ng-repeat="(pathname, doc) in history.selection.docs"
ng-class="{ 'selected': history.selection.pathname == pathname }"
)
.entity
.entity-name.entity-name-history(
ng-click="history.selection.pathname = pathname",
ng-class="{ 'deleted': !!doc.deletedAtV }"
)
i.fa.fa-fw.fa-pencil
span {{ pathname }}
script(type="text/ng-template", id="historyFileTreeTpl")
.history-file-tree
history-file-entity(
ng-repeat="fileEntity in $ctrl.fileTree"
file-entity="fileEntity"
ng-show="!$ctrl.isLoading"
)
script(type="text/ng-template", id="historyFileEntityTpl")
.history-file-entity-wrapper
a.history-file-entity-link(
href
ng-click="$ctrl.handleClick()"
ng-class="{ 'history-file-entity-link-selected': $ctrl.isSelected }"
)
span.history-file-entity-name
i.history-file-entity-icon.history-file-entity-icon-folder-state.fa.fa-fw(
ng-class="{\
'fa-chevron-down': ($ctrl.fileEntity.type === 'folder' && $ctrl.isOpen),\
'fa-chevron-right': ($ctrl.fileEntity.type === 'folder' && !$ctrl.isOpen)\
}"
)
i.history-file-entity-icon.fa(
ng-class="$ctrl.iconClass"
)
| {{ ::$ctrl.fileEntity.name }}
div(
ng-show="$ctrl.isOpen"
)
history-file-entity(
ng-repeat="childEntity in $ctrl.fileEntity.children"
file-entity="childEntity"
)

View file

@ -1,4 +1,7 @@
.diff-panel.full-size(ng-if="history.isV2", ng-controller="HistoryV2DiffController")
.diff-panel.full-size(
ng-if="history.isV2 && history.viewMode === HistoryViewModes.COMPARE"
ng-controller="HistoryV2DiffController"
)
.diff(
ng-if="!!history.diff && !history.diff.loading && !history.diff.error",
ng-class="{ 'diff-binary': history.diff.binary }"
@ -16,8 +19,13 @@
}"
)
| in <strong>{{history.diff.pathname}}</strong>
.history-toolbar-btn(
ng-click="toggleHistoryViewMode();"
)
i.fa
| #{translate("view_single_version")}
.toolbar-right(ng-if="history.selection.docs[history.selection.pathname].deletedAtV")
button.btn.btn-danger.btn-sm(
button.btn.btn-danger.btn-xs(
ng-click="restoreDeletedFile()"
ng-show="!restoreState.error"
ng-disabled="restoreState.inflight"
@ -48,3 +56,26 @@
| &nbsp;&nbsp;#{translate("loading")}...
.error-panel(ng-show="history.diff.error")
.alert.alert-danger #{translate("generic_something_went_wrong")}
.point-in-time-panel.full-size(
ng-if="history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME"
)
.point-in-time-editor-container(
ng-if="!!history.selectedFile && !history.selectedFile.loading && !history.selectedFile.error"
)
.hide-ace-cursor(
ng-if="!history.selectedFile.binary"
ace-editor="history-pointintime",
theme="settings.theme",
font-size="settings.fontSize",
text="history.selectedFile.text",
read-only="true",
resize-on="layout:main:resize",
)
.alert.alert-info(ng-if="history.selectedFile.binary")
| We're still working on showing image and binary changes, sorry. Stay tuned!
.loading-panel(ng-show="history.selectedFile.loading")
i.fa.fa-spin.fa-refresh
| &nbsp;&nbsp;#{translate("loading")}...
.error-panel(ng-show="history.selectedFile.error")
.alert.alert-danger #{translate("generic_something_went_wrong")}

View file

@ -0,0 +1,13 @@
.history-toolbar(
ng-if="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME"
)
span(ng-show="history.loadingFileTree")
i.fa.fa-spin.fa-refresh
| &nbsp;&nbsp; #{translate("loading")}...
span(ng-show="!history.loadingFileTree") #{translate("browsing_project_as_of")}&nbsp;
time.history-toolbar-time {{ history.selection.updates[0].meta.end_ts | formatDate:'Do MMM YYYY, h:mm a' }}
.history-toolbar-btn(
ng-click="toggleHistoryViewMode();"
)
i.fa
| #{translate("compare_to_another_version")}

View file

@ -62,6 +62,23 @@ aside#left-menu.full-size(
!= moduleIncludes("editorLeftMenu:editing_services", locals)
if showTestControls
h4 Test Controls
ul.list-unstyled.nav(ng-controller="TestControlsController")
li
a(href="#" ng-click="richText()")
i.fa.fa-exclamation.fa-fw
| Rich Text
li
a(href="#" ng-click="openProjectLinkedFileModal()")
i.fa.fa-exclamation.fa-fw
| Project-Linked-File Modal
li
a(href="#" ng-click="openLinkedFileModal()")
i.fa.fa-exclamation.fa-fw
| URL-Linked-File Modal
h4(ng-show="!anonymous") #{translate("settings")}
form.settings(ng-controller="SettingsController", ng-show="!anonymous")
.containter-fluid
@ -179,6 +196,7 @@ aside#left-menu.full-size(
option(value="pdfjs") #{translate("built_in")}
option(value="native") #{translate("native")}
h4 #{translate("hotkeys")}
ul.list-unstyled.nav
li(ng-controller="HotkeysController")

View file

@ -0,0 +1,26 @@
extends ../../layout
block content
script.
$(document).ready(function(){
$('#create_form').submit();
});
.editor.full-size
.loading-screen()
.loading-screen-brand-container
.loading-screen-brand(
style="height: 20%;"
)
h3.loading-screen-label() #{translate("Opening template")}
span.loading-screen-ellip .
span.loading-screen-ellip .
span.loading-screen-ellip .
form(id='create_form' method='POST' action='/project/new/template/')
input(type="hidden", name="_csrf", value=csrfToken)
input(type="hidden" name="templateId" value=templateId)
input(type="hidden" name="templateVersionId" value=templateVersionId)
input(type="hidden" name="templateName" value=name)
input(type="hidden" name="compiler" value=compiler)

View file

@ -1,4 +1,7 @@
.col-xs-6
- var titleClasses = settings.overleaf ? "col-xs-6 col-sm-4 col-md-6" : "col-xs-6"
- var lastUpdatedClasses = settings.overleaf ? " col-xs-4 col-sm-3 col-md-2" : "col-xs-4"
div(class=titleClasses)
input.select-item(
select-individual,
type="checkbox",
@ -37,8 +40,50 @@
tooltip-placement="right"
tooltip-append-to-body="true"
)
.col-xs-4
div(class=lastUpdatedClasses)
if settings.overleaf
span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}}
else
span.last-modified {{project.lastUpdated | formatDate}}
if settings.overleaf
.hidden-xs.col-sm-3.col-md-2.action-btn-row
button.btn.btn-link.action-btn(
tooltip=translate('copy'),
tooltip-placement="top",
tooltip-append-to-body="true",
ng-click="clone($event)"
)
i.icon.fa.fa-files-o
button.btn.btn-link.action-btn(
tooltip=translate('download'),
tooltip-placement="top",
tooltip-append-to-body="true",
ng-click="download($event)"
)
i.icon.fa.fa-cloud-download
button.btn.btn-link.action-btn(
ng-if="!project.archived && isOwner()"
tooltip=translate('archive'),
tooltip-placement="top",
tooltip-append-to-body="true",
ng-click="archiveOrLeave($event)"
)
i.icon.fa.fa-inbox
button.btn.btn-link.action-btn(
ng-if="!project.archived && !isOwner()"
tooltip=translate('leave'),
tooltip-placement="top",
tooltip-append-to-body="true",
ng-click="archiveOrLeave($event)"
)
i.icon.fa.fa-sign-out
button.btn.btn-link.action-btn(
ng-if="project.archived"
tooltip=translate('unarchive'),
tooltip-placement="top",
tooltip-append-to-body="true",
ng-click="restore($event)"
)
i.icon.fa.fa-reply

View file

@ -131,7 +131,10 @@
)
li.container-fluid
.row
.col-xs-6
- var titleClasses = settings.overleaf ? " col-xs-6 col-sm-4 col-md-6" : "col-xs-6"
- var lastUpdatedClasses = settings.overleaf ? " col-xs-4 col-sm-3 col-md-2" : "col-xs-4"
div(class=titleClasses)
input.select-all(
select-all,
type="checkbox"
@ -142,9 +145,12 @@
.col-xs-2
span.header.clickable(ng-click="changePredicate('accessLevel')") #{translate("owner")}
i.tablesort.fa(ng-class="getSortIconClass('accessLevel')")
.col-xs-4
div(class=lastUpdatedClasses)
span.header.clickable(ng-click="changePredicate('lastUpdated')") #{translate("last_modified")}
i.tablesort.fa(ng-class="getSortIconClass('lastUpdated')")
if settings.overleaf
.hidden-xs.col-sm-3.col-md-2.action-btn-row-header
span.header #{translate("actions")}
li.project_entry.container-fluid(
ng-repeat="project in visibleProjects | orderBy:predicate:reverse",
ng-controller="ProjectListItemController"

View file

@ -1,4 +1,4 @@
.col-xs-6
.col-xs-6.col-sm-4.col-md-6
.select-item
span.v1-badge(
aria-label=translate("v1_badge")
@ -21,5 +21,5 @@
.col-xs-2
span.owner {{ownerName()}}
.col-xs-4
.col-xs-4.col-sm-3.col-md-2
span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}}

View file

@ -0,0 +1,118 @@
.row
.col-md-12
.page-header.centered.plans-header.text-centered
h1 #{translate("start_x_day_trial", {len:'{{trial_len}}'})}
.row
.col-md-8.col-md-offset-2
p.text-centered #{translate("sl_benefits_plans")}
.row.top-switch
.col-md-6.col-md-offset-3
+plan_switch('card')
.col-md-2.text-right
+currency_dropdown
div(ng-show="showPlans")
.row
.col-md-10.col-md-offset-1
.row
.card-group.text-centered(ng-if="ui.view == 'monthly' || ui.view == 'annual'")
.col-md-4
.card.card-first
.card-header
h2 #{translate("personal")}
.circle #{translate("free")}
+features_free
.col-md-4
.card.card-highlighted
.card-header
h2 #{translate("collaborator")}
.circle
+price_collaborator
+features_collaborator
.col-md-4
.card.card-last
.card-header
h2 #{translate("professional")}
.circle
+price_professional
+features_professional
.card-group.text-centered(ng-if="ui.view == 'student'")
.col-md-4
.card.card-first
.card-header
h2 #{translate("personal")}
.circle #{translate("free")}
+features_free
.col-md-4
.card.card-highlighted
+card_student_monthly
.col-md-4
.card.card-last
+card_student_annual
.row.row-spaced
p.text-centered #{translate("choose_plan_works_for_you", {len:'{{trial_len}}'})}
.row
.col-md-8.col-md-offset-2
.alert.alert-info.text-centered
| #{translate("interested_in_group_licence")}
br
a(href, ng-click="openGroupPlanModal()") #{translate("get_in_touch_for_details")}
script(type="text/ng-template", id="groupPlanModalTemplate")
.modal-header
h3 #{translate("group_plan_enquiry")}
.modal-body
form.text-left.form(ng-controller="UniverstiesContactController", ng-submit="contactUs()")
span(ng-show="sent == false && error == false")
.form-group
label#title9(for='Field9')
| Name
input#Field9.field.text.medium.span8.form-control(ng-model="form.name", maxlength='255', tabindex='1', onkeyup='')
label#title11.desc(for='Field11')
| Email
.form-group
input#Field11.field.text.medium.span8.form-control(ng-model="form.email", name='Field11', type='email', spellcheck='false', value='', maxlength='255', tabindex='2')
label#title12.desc(for='Field12')
| University / Company
.form-group
input#Field12.field.text.medium.span8.form-control(ng-model="form.university", name='Field12', type='text', value='', maxlength='255', tabindex='3', onkeyup='')
label#title13.desc(for='Field13')
| Position
.form-group
input#Field13.field.text.medium.span8.form-control(ng-model="form.position", name='Field13', type='text', value='', maxlength='255', tabindex='4', onkeyup='')
.form-group
input(ng-model="form.source", type="hidden", ng-init="form.source = '__ref__'; form.subject = 'General enquiry for larger ShareLaTeX use';")
.form-group.text-center
input#saveForm.btn-success.btn.btn-lg(name='saveForm', type='submit', ng-disabled="sending", value='Request a quote')
span(ng-show="sent == true && error == false")
p Request Sent, Thank you.
span(ng-show="error")
p Error sending request.
.row
.col-md-12
.page-header.plans-header.plans-subheader.text-centered
h2 #{translate("enjoy_these_features")}
.col-md-4
.card.features.text-centered
i.fa.fa-file-text-o.fa-5x
h4 #{translate("unlimited_projects")}
p #{translate("create_unlimited_projects")}
.col-md-4
.card.features.text-centered
i.fa.fa-clock-o.fa-5x
h4 #{translate("full_doc_history")}
p #{translate("never_loose_work")}
.col-md-4
.card.features.text-centered
i.fa.fa-dropbox.fa-5x
| &nbsp; &nbsp;
i.fa.fa-github.fa-5x
h4 #{translate("sync_to_dropbox_and_github")}
p #{translate("access_projects_anywhere")}

View file

@ -0,0 +1,160 @@
.row
.col-md-12
.page-header.centered.plans-header.text-centered
h1.text-capitalize #{translate('instant_access')}
.row
.col-md-8.col-md-offset-2
p.text-centered #{translate("sl_benefits_plans")}
.row.top-switch
.col-md-6.col-md-offset-3
+plan_switch('card')
.col-md-2.text-right
+currency_dropdown
div(ng-show="showPlans")
.row
.col-md-10.col-md-offset-1
.row
.card-group.text-centered(ng-if="ui.view == 'monthly' || ui.view == 'annual'")
.col-md-4
.card.card-first
.card-header
h2 #{translate("personal")}
h5.tagline #{translate("tagline_personal")}
.circle #{translate("free")}
+features_free
.col-md-4
.card.card-highlighted
.best-value
strong #{translate('best_value')}
.card-header
h2 #{translate("collaborator")}
h5.tagline #{translate("tagline_collaborator")}
.circle
+price_collaborator
+features_collaborator
.col-md-4
.card.card-last
.card-header
h2 #{translate("professional")}
h5.tagline #{translate("tagline_professional")}
.circle
+price_professional
+features_professional
.card-group.text-centered(ng-if="ui.view == 'student'")
.col-md-4
.card.card-first
.card-header
h2 #{translate("personal")}
h5.tagline #{translate("tagline_personal")}
.circle #{translate("free")}
+features_free
.col-md-4
.card.card-highlighted
+card_student_annual
.col-md-4
.card.card-last
+card_student_monthly
.row.row-spaced-large.text-centered
i.fa.fa-cc-mastercard.fa-2x &nbsp;
i.fa.fa-cc-visa.fa-2x &nbsp;
i.fa.fa-cc-amex.fa-2x &nbsp;
i.fa.fa-cc-paypal.fa-2x &nbsp;
div.text-centered #{translate('change_plans_any_time')}<br/> #{translate('billed_after_x_days', {len:'{{trial_len}}'})}
.row.row-spaced-large
.col-md-8.col-md-offset-2
.card.text-centered
.card-header
h2 #{translate('looking_multiple_licenses')}
span #{translate('reduce_costs_group_licenses')}
br
br
a.btn.btn-info(href="/i/university/groups") #{translate('find_out_more')}
div
.row.row-spaced-large
.col-sm-12
.page-header.plans-header.plans-subheader.text-centered
h2 #{translate('compare_plan_features')}
.row
.col-md-6.col-md-offset-3
+plan_switch('table')
.col-md-3.text-right
+currency_dropdown
.row(event-tracking="features-table-viewed" event-tracking-ga="subscription-funnel" event-tracking-trigger="scroll" event-tracking-send-once="true")
.col-sm-12(ng-if="ui.view != 'student'")
+table_premium
.col-sm-12(ng-if="ui.view == 'student'")
+table_student
.row.row-spaced-large
.col-md-12
.page-header.plans-header.plans-subheader.text-centered
h2 #{translate('in_good_company')}
.row
.col-md-6
div
.row
.col-md-3
.circle-img
img(src=buildImgPath('advocates/erdogmus.jpg') alt="Professor Erdogmus")
.col-md-9
blockquote
p The ability to track changes and the real-time collaborative nature is what sets ShareLaTeX apart.
footer Professor Erdogmus, Northeastern University
.col-md-6
div
.row
.col-md-3
.circle-img
img(src=buildImgPath('advocates/henderson.jpg') alt="Rob Henderson")
.col-md-9
blockquote
p ShareLaTeX has proven to be a powerful and robust collaboration tool that is widely used in our School.
footer Rob Henderson, School Of Informatics And Computing - Indiana University
.faq
.row.row-spaced-large
.col-md-12
.page-header.plans-header.plans-subheader.text-centered
h2 FAQ
.row
.col-md-6
h3 #{translate("faq_how_free_trial_works_question")}
p #{translate('faq_how_free_trial_works_answer', { len:'{{trial_len}}' })}
.col-md-6
h3 #{translate('faq_change_plans_question')}
p #{translate('faq_change_plans_answer')}
.row
.col-md-6
h3 #{translate('faq_do_collab_need_premium_question')}
p #{translate('faq_do_collab_need_premium_answer')}
.col-md-6
h3 #{translate('faq_need_more_collab_question')}
p !{translate('faq_need_more_collab_answer', { referFriendsLink: '<a href="/user/bonus">' + translate('referring_your_friends') + '</a>'})}
.row
.col-md-6
h3 #{translate('faq_purchase_more_licenses_question')}
p !{translate('faq_purchase_more_licenses_answer', { groupLink: '<a href="/i/university/groups">' + translate('discounted_group_accounts') + '</a>' })}
.col-md-6
h3 #{translate('faq_monthly_or_annual_question')}
p #{translate('faq_monthly_or_annual_answer')}
.row
.col-md-6
h3 #{translate('faq_how_to_pay_question')}
p #{translate('faq_how_to_pay_answer')}
.col-md-6
h3 #{translate('faq_pay_by_invoice_question')}
p !{translate('faq_pay_by_invoice_answer', { groupLink: '<a href="/i/university/groups">' + translate('discounted_group_accounts') + '</a>' })}
.row.row-spaced-large.text-centery
.col-md-12
.plans-header.plans-subheader.text-centered
h2 #{translate('still_have_questions')}
button.btn.btn-info.btn-header.text-capitalize(ng-controller="ContactGeneralModal" ng-click="openModal()") #{translate('get_in_touch')}
!= moduleIncludes("contactModalGeneral", locals)

View file

@ -0,0 +1,162 @@
//- Buy Buttons
mixin btn_buy_collaborator(location)
a.btn.btn-info(
ng-href="/user/subscription/new?planCode={{ getCollaboratorPlanCode() }}&currency={{currencyCode}}",
ng-click="signUpNowClicked('collaborator','" + location + "')"
)
span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
mixin btn_buy_free(location)
a.btn.btn-info(
href="/register"
style=(getLoggedInUserId() === null ? "" : "visibility: hidden")
ng-click="signUpNowClicked('free','" + location + "')"
)
span(ng-if="plansVariant !== 'more-details'") #{translate('sign_up_now')}
span.text-capitalize(ng-if="plansVariant === 'more-details'") #{translate('get_started_now')}
mixin btn_buy_professional(location)
a.btn.btn-info(
ng-href="/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || planQueryString}}&currency={{currencyCode}}"
ng-click="signUpNowClicked('professional','" + location + "')"
)
span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
mixin btn_buy_student(location, plan)
if plan == 'annual'
a.btn.btn-info(
ng-href="/user/subscription/new?planCode=student-annual&currency={{currencyCode}}",
ng-click="signUpNowClicked('student-annual','" + location + "')"
) #{translate("buy_now")}
else
//- planQueryString will contain _free_trial_7_days
a.btn.btn-info(
ng-href="/user/subscription/new?planCode=student{{planQueryString}}&currency={{currencyCode}}",
ng-click="signUpNowClicked('student-monthly','" + location + "')"
) #{translate("start_free_trial")}
//- Cards
mixin card_student_annual
.best-value(ng-if="plansVariant == 'more-details'")
strong #{translate('best_value')}
.card-header
h2 #{translate("student")} (#{translate("annual")})
h5.tagline(ng-if="plansVariant == 'more-details'") #{translate('tagline_student_annual')}
.circle
span
+price_student_annual
+features_student('card', 'annual')
mixin card_student_monthly
.card-header
h2 #{translate("student")}
h5.tagline(ng-if="plansVariant == 'more-details'") #{translate('tagline_student_monthly')}
.circle
span
+price_student_monthly
+features_student('card', 'monthly')
//- Features Lists
mixin features_collaborator
ul.list-unstyled
li
strong #{translate("collabs_per_proj", {collabcount:10})}
+features_premium
li
br
+btn_buy_collaborator('card')
mixin features_free
ul.list-unstyled
li #{translate("one_collaborator")}
li(class="hidden-xs hidden-sm") &nbsp;
li(class="hidden-xs hidden-sm") &nbsp;
li(class="hidden-xs hidden-sm") &nbsp;
li(class="hidden-xs hidden-sm" ng-if="plansVariant === 'more-details'") &nbsp;
li(class="hidden-xs hidden-sm" ng-if="plansVariant === 'more-details'") &nbsp;
li(class="hidden-xs hidden-sm" ng-if="plansVariant === 'more-details'") &nbsp;
li
br
+btn_buy_free('card')
mixin features_premium
li(ng-if="plansVariant != 'more-details'") #{translate("full_doc_history")}
li(ng-if="plansVariant != 'more-details'") #{translate("sync_to_dropbox")}
li(ng-if="plansVariant != 'more-details'") #{translate("sync_to_github")}
li(ng-if="plansVariant === 'more-details'") &nbsp;
li(ng-if="plansVariant === 'more-details'")
strong #{translate('all_premium_features')}
li(ng-if="plansVariant === 'more-details'") #{translate('sync_dropbox_github')}
li(ng-if="plansVariant === 'more-details'") #{translate('full_doc_history')}
li(ng-if="plansVariant === 'more-details'") #{translate('track_changes')}
li(ng-if="plansVariant === 'more-details'") + #{translate('more').toLowerCase()}
mixin features_professional
ul.list-unstyled
li
strong #{translate("unlimited_collabs")}
+features_premium
li
br
+btn_buy_professional('card')
mixin features_student(location, plan)
ul.list-unstyled
li
strong #{translate("collabs_per_proj", {collabcount:6})}
+features_premium
li
br
+btn_buy_student(location, plan)
//- Prices
mixin price_collaborator
span(ng-if="ui.view == 'monthly'")
| {{plans[currencyCode]['collaborator']['monthly']}}
span.small /mo
span(ng-if="ui.view == 'annual'")
| {{plans[currencyCode]['collaborator']['annual']}}
span.small /yr
mixin price_professional
span(ng-if="ui.view == 'monthly'")
| {{plans[currencyCode]['professional']['monthly']}}
span.small /mo
span(ng-if="ui.view == 'annual'")
| {{plans[currencyCode]['professional']['annual']}}
span.small /yr
mixin price_student_annual
| {{plans[currencyCode]['student']['annual']}}
span.small /yr
mixin price_student_monthly
| {{plans[currencyCode]['student']['monthly']}}
span.small /mo
//- UI Control
mixin currency_dropdown
.dropdown.currency-dropdown(dropdown)
a.btn.btn-default.dropdown-toggle(
href="#",
data-toggle="dropdown",
dropdown-toggle
)
| {{currencyCode}} ({{plans[currencyCode]['symbol']}})
span.caret
ul.dropdown-menu.dropdown-menu-right.text-right(role="menu")
li(ng-repeat="(currency, value) in plans")
a(
href="#",
ng-click="changeCurreny($event, currency)"
) {{currency}} ({{value['symbol']}})
mixin plan_switch(location)
ul.nav.nav-pills
li(ng-class="{'active': ui.view == 'monthly'}")
a(
href="#"
ng-click="switchToMonthly($event,'" + location + "')"
) #{translate("monthly")}
li(ng-class="{'active': ui.view == 'annual'}")
a(
href="#"
ng-click="switchToAnnual($event,'" + location + "')"
) #{translate("annual")}
li(ng-class="{'active': ui.view == 'student'}")
a(
href="#"
ng-click="switchToStudent($event,'" + location + "')"
) #{translate("half_price_student")}

View file

@ -0,0 +1,107 @@
//- Features Tables
mixin table_premium
table.card.plans-table
tr
th
th #{translate("personal")}
th #{translate("collaborator")}
.outer.outer-top
.outer-content
.best-value
strong #{translate('best_value')}
th #{translate("professional")}
tr
td #{translate("price")}
td #{translate("free")}
td
+price_collaborator
td
+price_professional
for feature in planFeatures
tr
td(event-tracking="features-table" event-tracking-trigger="hover" event-tracking-ga="subscription-funnel" event-tracking-label=`${feature.feature}-exp-{{plansVariant}}`)
if feature.info
span(tooltip=translate(feature.info)) #{translate(feature.feature)}
else
| #{translate(feature.feature)}
for plan in feature.plans
td
if feature.value == 'str'
| #{plan}
else if plan
i.fa.fa-check
else
i.fa.fa-times
tr
td
td
+btn_buy_free('table')
td
+btn_buy_collaborator('table')
.outer.outer-btm
.outer-content &nbsp;
td
+btn_buy_professional('table')
mixin table_cell_student(feature)
if feature.value == 'str'
| #{feature.student}
else if feature.student
i.fa.fa-check
else
i.fa.fa-times
mixin table_student
table.card.plans-table
tr
th
th #{translate("personal")}
th #{translate("student")} (#{translate("annual")})
.outer.outer-top
.outer-content
.best-value
strong Best Value
th #{translate("student")}
tr
td #{translate("price")}
td #{translate("free")}
td
+price_student_annual
td
+price_student_monthly
for feature in planFeatures
tr
td(event-tracking="plans-page-table" event-tracking-trigger="hover" event-tracking-ga="subscription-funnel" event-tracking-label=`${feature.feature}-exp-{{plansVariant}}`)
if feature.info
span(tooltip=translate(feature.info)) #{translate(feature.feature)}
else
| #{translate(feature.feature)}
td
if feature.value == 'str'
| #{feature.plans.free}
else if feature.plans.free
i.fa.fa-check
else
i.fa.fa-times
td
+table_cell_student(feature)
td
+table_cell_student(feature)
tr
td
td
+btn_buy_free('table')
td
+btn_buy_student('table', 'annual')
.outer.outer-btm
.outer-content &nbsp;
td
+btn_buy_student('table', 'monthly')

View file

@ -32,6 +32,9 @@ block content
a(
ng-click="changeCurrency(currency)",
) {{currency}} ({{value['symbol']}})
.row(ng-if="plansVariant == 'more-details' && planCode == 'student-annual' || plansVariant == 'more-details' && planCode == 'student-monthly'")
.col-xs-12
p.student-disclaimer #{translate('student_disclaimer')}
hr.thin
.row
.col-md-12.text-center

View file

@ -1,253 +1,18 @@
extends ../layout
include _plans_page_mixins
include _plans_page_tables
block scripts
script(type='text/javascript').
window.recomendedCurrency = '#{recomendedCurrency}'
window.abCurrencyFlag = '#{abCurrencyFlag}'
window.shouldABTestPlans = #{shouldABTestPlans || false}
script(type='text/javascript').
(function() {var s=document.createElement('script'); s.type='text/javascript';s.async=true;
s.src=('https:'==document.location.protocol?'https':'http') + '://sharelatex-accounts.groovehq.com/widgets/f5ad3b09-7d99-431b-8af5-c5725e3760ce/ticket/api.js';
var q = document.getElementsByTagName('script')[0];q.parentNode.insertBefore(s, q);})();
block content
.content.content-alt
.content.plans(ng-controller="PlansController")
.container
.row
.col-md-12
.page-header.centered.plans-header.text-centered
h1(ng-cloak) #{translate("start_x_day_trial", {len:'{{trial_len}}'})}
.row
.col-md-8.col-md-offset-2
p.text-centered #{translate("sl_benefits_plans")}
.row(ng-cloak)
.col-md-6.col-md-offset-3
ul.nav.nav-pills
li(ng-class="{'active': ui.view == 'monthly'}")
a(
href,
ng-click="switchToMonthly()"
) #{translate("monthly")}
li(ng-class="{'active': ui.view == 'annual'}")
a(
href
ng-click="switchToAnnual()"
) #{translate("annual")}
li(ng-class="{'active': ui.view == 'student'}")
a(
href,
ng-click="switchToStudent()"
) #{translate("half_price_student")}
.col-md-2.text-right
.dropdown.currency-dropdown(dropdown)
a.btn.btn-default.dropdown-toggle#currenyDropdown(
href="#",
data-toggle="dropdown",
dropdown-toggle
)
| {{currencyCode}} ({{plans[currencyCode]['symbol']}})
span.caret
ul.dropdown-menu.dropdown-menu-right.text-right(role="menu")
li(ng-repeat="(currency, value) in plans")
a(
href,
ng-click="changeCurreny(currency)"
) {{currency}} ({{value['symbol']}})
div(ng-show="showPlans")
.row(ng-cloak)
.col-md-10.col-md-offset-1
.row
.card-group.text-centered(ng-if="ui.view == 'monthly' || ui.view == 'annual'")
.col-md-4
.card.card-first
.card-header
h2 #{translate("personal")}
.circle #{translate("free")}
ul.list-unstyled
li #{translate("one_collaborator")}
li &nbsp;
li &nbsp;
li &nbsp;
li
br
a.btn.btn-info(
href="/register"
style=(getLoggedInUserId() === null ? "" : "visibility: hidden")
) #{translate("sign_up_now")}
.col-md-4
.card.card-highlighted
.card-header
h2 #{translate("collaborator")}
.circle
span(ng-if="ui.view == 'monthly'")
| {{plans[currencyCode]['collaborator']['monthly']}}
span.small /mo
span(ng-if="ui.view == 'annual'")
| {{plans[currencyCode]['collaborator']['annual']}}
span.small /yr
ul.list-unstyled
li
strong #{translate("collabs_per_proj", {collabcount:10})}
li #{translate("full_doc_history")}
li #{translate("sync_to_dropbox")}
li #{translate("sync_to_github")}
li
br
a.btn.btn-info(
ng-href="/user/subscription/new?planCode={{ getCollaboratorPlanCode() }}&currency={{currencyCode}}", ng-click="signUpNowClicked('collaborator')"
)
span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
.col-md-4
.card.card-last
.card-header
h2 #{translate("professional")}
.circle
span(ng-if="ui.view == 'monthly'")
| {{plans[currencyCode]['professional']['monthly']}}
span.small /mo
span(ng-if="ui.view == 'annual'")
| {{plans[currencyCode]['professional']['annual']}}
span.small /yr
ul.list-unstyled
li
strong #{translate("unlimited_collabs")}
li #{translate("full_doc_history")}
li #{translate("sync_to_dropbox")}
li #{translate("sync_to_github")}
li
br
a.btn.btn-info(
ng-href="/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || planQueryString}}&currency={{currencyCode}}", ng-click="signUpNowClicked('professional')"
)
span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
.card-group.text-centered(ng-if="ui.view == 'student'")
.col-md-4
.card.card-first
.card-header
h2 #{translate("personal")}
.circle #{translate("free")}
ul.list-unstyled
li #{translate("one_collaborator")}
li &nbsp;
li &nbsp;
li &nbsp;
li
br
a.btn.btn-info(
href="/register"
style=(getLoggedInUserId() === null ? "" : "visibility: hidden")
) #{translate("sign_up_now")}
.col-md-4
.card.card-highlighted
.card-header
h2 #{translate("student")}
.circle
span
| {{plans[currencyCode]['student']['monthly']}}
span.small /mo
ul.list-unstyled
li
strong #{translate("collabs_per_proj", {collabcount:6})}
li #{translate("full_doc_history")}
li #{translate("sync_to_dropbox")}
li #{translate("sync_to_github")}
li
br
a.btn.btn-info(
ng-href="/user/subscription/new?planCode=student{{ plansVariant == 'default' ? planQueryString : '_'+plansVariant }}&currency={{currencyCode}}",
ng-click="signUpNowClicked('student-monthly')"
) #{translate("start_free_trial")}
.col-md-4
.card.card-last
.card-header
h2 #{translate("student")} (#{translate("annual")})
.circle
span
| {{plans[currencyCode]['student']['annual']}}
span.small /yr
ul.list-unstyled
li
strong #{translate("collabs_per_proj", {collabcount:6})}
li #{translate("full_doc_history")}
li #{translate("sync_to_dropbox")}
li #{translate("sync_to_github")}
li
br
a.btn.btn-info(
ng-href="/user/subscription/new?planCode=student-annual{{ plansVariant == 'default' ? '' : '_'+plansVariant }}&currency={{currencyCode}}",
ng-click="signUpNowClicked('student-annual')"
) #{translate("buy_now")}
.row.row-spaced(ng-cloak)
p.text-centered #{translate("choose_plan_works_for_you", {len:'{{trial_len}}'})}
.row(ng-cloak)
.col-md-8.col-md-offset-2
.alert.alert-info.text-centered
| #{translate("interested_in_group_licence")}
br
a(href, ng-click="openGroupPlanModal()") #{translate("get_in_touch_for_details")}
script(type="text/ng-template", id="groupPlanModalTemplate")
.modal-header
h3 #{translate("group_plan_enquiry")}
.modal-body
form.text-left.form(ng-controller="UniverstiesContactController", ng-submit="contactUs()", ng-cloak)
span(ng-show="sent == false && error == false")
.form-group
label#title9(for='Field9')
| Name
input#Field9.field.text.medium.span8.form-control(ng-model="form.name", maxlength='255', tabindex='1', onkeyup='')
label#title11.desc(for='Field11')
| Email
.form-group
input#Field11.field.text.medium.span8.form-control(ng-model="form.email", name='Field11', type='email', spellcheck='false', value='', maxlength='255', tabindex='2')
label#title12.desc(for='Field12')
| University / Company
.form-group
input#Field12.field.text.medium.span8.form-control(ng-model="form.university", name='Field12', type='text', value='', maxlength='255', tabindex='3', onkeyup='')
label#title13.desc(for='Field13')
| Position
.form-group
input#Field13.field.text.medium.span8.form-control(ng-model="form.position", name='Field13', type='text', value='', maxlength='255', tabindex='4', onkeyup='')
.form-group
input(ng-model="form.source", type="hidden", ng-init="form.source = '__ref__'; form.subject = 'General enquiry for larger ShareLaTeX use';")
.form-group.text-center
input#saveForm.btn-success.btn.btn-lg(name='saveForm', type='submit', ng-disabled="sending", value='Request a quote')
span(ng-show="sent == true && error == false")
p Request Sent, Thank you.
span(ng-show="error")
p Error sending request.
.row
.col-md-12
.page-header.plans-header.plans-subheader.text-centered
h2 #{translate("enjoy_these_features")}
.col-md-4
.card.features.text-centered
i.fa.fa-file-text-o.fa-5x
h4 #{translate("unlimited_projects")}
p #{translate("create_unlimited_projects")}
.col-md-4
.card.features.text-centered
i.fa.fa-clock-o.fa-5x
h4 #{translate("full_doc_history")}
p #{translate("never_loose_work")}
.col-md-4
.card.features.text-centered
i.fa.fa-dropbox.fa-5x
| &nbsp; &nbsp;
i.fa.fa-github.fa-5x
h4 #{translate("sync_to_dropbox_and_github")}
p #{translate("access_projects_anywhere")}
.container(class="more-details" ng-cloak ng-if="plansVariant === 'more-details'")
include _plans_page_details_more
.container(ng-cloak ng-if="plansVariant != 'more-details'")
include _plans_page_details_less

View file

@ -146,8 +146,8 @@ module.exports = settings =
url: "http://#{process.env['CONTACTS_HOST'] or 'localhost'}:3036"
sixpack:
url: ""
# references:
# url: "http://localhost:3040"
references:
url: "http://#{process.env['REFERENCES_HOST'] or 'localhost'}:3040"
notifications:
url: "http://#{process.env['NOTIFICATIONS_HOST'] or 'localhost'}:3042"
analytics:

View file

@ -17,6 +17,7 @@ services:
PROJECT_HISTORY_ENABLED: 'true'
ENABLED_LINKED_FILE_TYPES: 'url'
LINKED_URL_PROXY: 'http://localhost:6543'
ENABLED_LINKED_FILE_TYPES: 'url,project_file'
SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee
depends_on:
- redis

View file

@ -4,11 +4,23 @@
# event not sent to MB.
# for MB, add event-tracking-mb='true'
# by default, event sent to MB via sendMB
# this can be changed to use sendMBOnce via event-tracking-send-once='true' attribute
# event not sent to GA.
# for GA, add event-tracking-ga attribute, where the value is the GA category
# Either GA or MB can use the attribute event-tracking-send-once='true' to
# send event just once
# MB will use the key and GA will use the action to determine if the event
# has been sent
# event-tracking-trigger attribute is required to send event
isInViewport = (element) ->
elTop = element.offset().top
elBtm = elTop + element.outerHeight()
viewportTop = $(window).scrollTop()
viewportBtm = viewportTop + $(window).height()
elBtm > viewportTop && elTop < viewportBtm
define [
'base'
], (App) ->
@ -22,20 +34,42 @@ define [
sendGA = attrs.eventTrackingGa || false
sendMB = attrs.eventTrackingMb || false
sendMBFunction = if attrs.eventTrackingSendOnce then 'sendMBOnce' else 'sendMB'
sendGAFunction = if attrs.eventTrackingSendOnce then 'sendGAOnce' else 'send'
segmentation = scope.eventSegmentation || {}
segmentation.page = window.location.pathname
sendEvent = () ->
sendEvent = (scrollEvent) ->
###
@param {boolean} scrollEvent Use to unbind scroll event
###
if sendMB
event_tracking[sendMBFunction] scope.eventTracking, segmentation
if sendGA
event_tracking.send attrs.eventTrackingGa, attrs.eventTrackingAction || scope.eventTracking, attrs.eventTrackingLabel || ''
event_tracking[sendGAFunction] attrs.eventTrackingGa, attrs.eventTrackingAction || scope.eventTracking, attrs.eventTrackingLabel || ''
if scrollEvent
$(window).unbind('resize scroll')
if attrs.eventTrackingTrigger == 'load'
sendEvent()
else if attrs.eventTrackingTrigger == 'click'
element.on 'click', (e) ->
sendEvent()
else if attrs.eventTrackingTrigger == 'hover'
timer = null
timeoutAmt = 500
if attrs.eventHoverAmt
timeoutAmt = parseInt(attrs.eventHoverAmt, 10)
element.on 'mouseenter', () ->
timer = setTimeout((-> sendEvent()), timeoutAmt)
return
.on 'mouseleave', () ->
clearTimeout(timer)
else if attrs.eventTrackingTrigger == 'scroll'
if !event_tracking.eventInCache(scope.eventTracking)
$(window).on 'resize scroll', () ->
_.throttle(
if isInViewport(element) && !event_tracking.eventInCache(scope.eventTracking)
sendEvent(true)
, 500)
}
]

View file

@ -18,6 +18,7 @@ define [
"ide/chat/index"
"ide/clone/index"
"ide/hotkeys/index"
"ide/test-controls/index"
"ide/wordcount/index"
"ide/directives/layout"
"ide/directives/validFile"
@ -34,6 +35,7 @@ define [
"directives/videoPlayState"
"services/queued-http"
"services/validateCaptcha"
"services/wait-for"
"filters/formatDate"
"main/event"
"main/account-upgrade"
@ -54,7 +56,7 @@ define [
SafariScrollPatcher
) ->
App.controller "IdeController", ($scope, $timeout, ide, localStorage, sixpack, event_tracking, metadata) ->
App.controller "IdeController", ($scope, $timeout, ide, localStorage, sixpack, event_tracking, metadata, $q) ->
# Don't freak out if we're already in an apply callback
$scope.$originalApply = $scope.$apply
$scope.$apply = (fn = () ->) ->
@ -211,11 +213,10 @@ define [
try
chromeVersion = parseFloat(navigator.userAgent.split(" Chrome/")[1]) || null;
browserIsChrome61or62 = (
chromeVersion? &&
(chromeVersion == 61 || chromeVersion == 62)
chromeVersion?
)
if browserIsChrome61or62
document.styleSheets[0].insertRule(".ace_editor.ace_autocomplete .ace_completion-highlight { text-shadow: none !important; }", 1)
document.styleSheets[0].insertRule(".ace_editor.ace_autocomplete .ace_completion-highlight { text-shadow: none !important; font-weight: bold; }", 1)
catch err
console.error err

View file

@ -2,7 +2,7 @@ define [
"base"
"moment"
], (App, moment) ->
App.controller "BinaryFileController", ["$scope", "$rootScope", "$http", "$timeout", "$element", "ide", ($scope, $rootScope, $http, $timeout, $element, ide) ->
App.controller "BinaryFileController", ["$scope", "$rootScope", "$http", "$timeout", "$element", "ide", "waitFor", ($scope, $rootScope, $http, $timeout, $element, ide, waitFor) ->
TWO_MEGABYTES = 2 * 1024 * 1024
@ -31,6 +31,7 @@ define [
data: null
$scope.refreshing = false
$scope.refreshError = null
MAX_URL_LENGTH = 60
FRONT_OF_URL_LENGTH = 35
@ -48,9 +49,27 @@ define [
$scope.refreshFile = (file) ->
$scope.refreshing = true
$scope.refreshError = null
ide.fileTreeManager.refreshLinkedFile(file)
.then () ->
loadTextFileFilePreview()
.then (response) ->
{ data } = response
{ new_file_id } = data
$timeout(
() ->
waitFor(
() ->
ide.fileTreeManager.findEntityById(new_file_id)
5000
)
.then (newFile) ->
ide.binaryFilesManager.openFile(newFile)
.catch (err) ->
console.warn(err)
, 0
)
$scope.refreshError = null
.catch (response) ->
$scope.refreshError = response.data
.finally () ->
$scope.refreshing = false
@ -86,10 +105,8 @@ define [
# show dots when payload is closs to cutoff
if data.length >= (TWO_MEGABYTES - 200)
$scope.textPreview.shouldShowDots = true
try
# remove last partial line
data = data.replace(/\n.*$/, '')
finally
data = data?.replace?(/\n.*$/, '')
$scope.textPreview.data = data
$timeout(setHeight, 0)
.catch (error) ->

View file

@ -14,7 +14,7 @@ define [
opening: true
trackChanges: false
wantTrackChanges: false
richText: false
showRichText: false
}
@$scope.$on "entity:selected", (event, entity) =>

View file

@ -335,6 +335,11 @@ define [
return null
projectContainsFolder: () ->
for entity in @$scope.rootFolder.children
return true if entity.type == 'folder'
return false
existsInThisFolder: (folder, name) ->
for entity in folder?.children or []
return true if entity.name is name

View file

@ -43,6 +43,19 @@ define [
}
)
$scope.openProjectLinkedFileModal = window.openProjectLinkedFileModal = () ->
unless 'project_file' in window.data.enabledLinkedFileTypes
console.warn("Project linked files are not enabled")
return
$modal.open(
templateUrl: "projectLinkedFileModalTemplate"
controller: "ProjectLinkedFileModalController"
scope: $scope
resolve: {
parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
}
)
$scope.orderByFoldersFirst = (entity) ->
return '0' if entity?.type == "folder"
return '1'
@ -201,6 +214,117 @@ define [
$modalInstance.dismiss('cancel')
]
App.controller "ProjectLinkedFileModalController", [
"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
($scope, ide, $modalInstance, $timeout, parent_folder) ->
$scope.data =
projects: null # or []
selectedProjectId: null
projectEntities: null # or []
selectedProjectEntity: null
name: null
$scope.state =
inFlight:
projects: false
entities: false
create: false
error: false
$scope.$watch 'data.selectedProjectId', (newVal, oldVal) ->
return if !newVal
$scope.data.selectedProjectEntity = null
$scope.getProjectEntities($scope.data.selectedProjectId)
# auto-set filename based on selected file
$scope.$watch 'data.selectedProjectEntity', (newVal, oldVal) ->
return if !newVal
fileName = newVal.split('/').reverse()[0]
if fileName
$scope.data.name = fileName
_setInFlight = (type) ->
$scope.state.inFlight[type] = true
_reset = (opts) ->
isError = opts.err == true
inFlight = $scope.state.inFlight
inFlight.projects = inFlight.entities = inFlight.create = false
$scope.state.error = isError
$scope.shouldEnableProjectSelect = () ->
{ state, data } = $scope
return !state.inFlight.projects && data.projects
$scope.shouldEnableProjectEntitySelect = () ->
{ state, data } = $scope
return !state.inFlight.projects && !state.inFlight.entities && data.projects && data.selectedProjectId
$scope.shouldEnableCreateButton = () ->
state = $scope.state
data = $scope.data
return !state.inFlight.projects &&
!state.inFlight.entities &&
data.projects &&
data.selectedProjectId &&
data.projectEntities &&
data.selectedProjectEntity &&
data.name
$scope.getUserProjects = () ->
_setInFlight('projects')
ide.$http.get("/user/projects", {
_csrf: window.csrfToken
})
.then (resp) ->
$scope.data.projectEntities = null
$scope.data.projects = resp.data.projects.filter (p) ->
p._id != ide.project_id
_reset(err: false)
.catch (err) ->
_reset(err: true)
$scope.getProjectEntities = (project_id) =>
_setInFlight('entities')
ide.$http.get("/project/#{project_id}/entities", {
_csrf: window.csrfToken
})
.then (resp) ->
if $scope.data.selectedProjectId == resp.data.project_id
$scope.data.projectEntities = resp.data.entities
_reset(err: false)
.catch (err) ->
_reset(err: true)
$scope.init = () ->
$scope.getUserProjects()
$timeout($scope.init, 0)
$scope.create = () ->
projectId = $scope.data.selectedProjectId
path = $scope.data.selectedProjectEntity
name = $scope.data.name
if !name || !path || !projectId
_reset(err: true)
return
_setInFlight('create')
ide.fileTreeManager
.createLinkedFile(name, parent_folder, 'project_file', {
source_project_id: projectId,
source_entity_path: path
})
.then () ->
_reset(err: false)
$modalInstance.close()
.catch (response)->
{ data } = response
_reset(err: true)
$scope.cancel = () ->
$modalInstance.dismiss('cancel')
]
# TODO: rename all this to UrlLinkedFilModalController
App.controller "LinkedFileModalController", [
"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
($scope, ide, $modalInstance, $timeout, parent_folder) ->

View file

@ -1,6 +1,7 @@
define [
"base"
], (App) ->
"ide/file-tree/util/iconTypeFromName"
], (App, iconTypeFromName) ->
App.controller "FileTreeEntityController", ["$scope", "ide", "$modal", ($scope, ide, $modal) ->
$scope.select = (e) ->
if e.ctrlKey or e.metaKey
@ -70,18 +71,7 @@ define [
$scope.$on "delete:selected", () ->
$scope.openDeleteModal() if $scope.entity.selected
$scope.iconTypeFromName = (name) ->
ext = name.split(".").pop()?.toLowerCase()
if ext in ["png", "pdf", "jpg", "jpeg", "gif"]
return "image"
else if ext in ["csv", "xls", "xlsx"]
return "table"
else if ext in ["py", "r"]
return "file-text"
else if ext in ['bib']
return 'book'
else
return "file"
$scope.iconTypeFromName = iconTypeFromName
]
App.controller "DeleteEntityModalController", [

View file

@ -0,0 +1,13 @@
define [], () ->
return iconTypeFromName = (name) ->
ext = name.split(".").pop()?.toLowerCase()
if ext in ["png", "pdf", "jpg", "jpeg", "gif"]
return "image"
else if ext in ["csv", "xls", "xlsx"]
return "table"
else if ext in ["py", "r"]
return "file-text"
else if ext in ['bib']
return 'book'
else
return "file"

View file

@ -4,7 +4,6 @@ define [
"ide/history/util/displayNameForUser"
"ide/history/controllers/HistoryListController"
"ide/history/controllers/HistoryDiffController"
"ide/history/controllers/HistoryV2DiffController"
"ide/history/directives/infiniteScroll"
], (moment, ColorManager, displayNameForUser) ->
class HistoryManager

View file

@ -2,13 +2,20 @@ define [
"moment"
"ide/colors/ColorManager"
"ide/history/util/displayNameForUser"
"ide/history/controllers/HistoryListController"
"ide/history/controllers/HistoryDiffController"
"ide/history/util/HistoryViewModes"
"ide/history/controllers/HistoryV2ListController"
"ide/history/controllers/HistoryV2DiffController"
"ide/history/controllers/HistoryV2FileTreeController"
"ide/history/directives/infiniteScroll"
], (moment, ColorManager, displayNameForUser) ->
"ide/history/components/historyEntriesList"
"ide/history/components/historyEntry"
"ide/history/components/historyFileTree"
"ide/history/components/historyFileEntity"
], (moment, ColorManager, displayNameForUser, HistoryViewModes) ->
class HistoryManager
constructor: (@ide, @$scope) ->
@reset()
@$scope.HistoryViewModes = HistoryViewModes
@$scope.toggleHistory = () =>
if @$scope.ui.view == "history"
@ -16,17 +23,31 @@ define [
else
@show()
@$scope.toggleHistoryViewMode = () =>
if @$scope.history.viewMode == HistoryViewModes.COMPARE
@reset()
@$scope.history.viewMode = HistoryViewModes.POINT_IN_TIME
else
@reset()
@$scope.history.viewMode = HistoryViewModes.COMPARE
@$scope.$watch "history.selection.updates", (updates) =>
if @$scope.history.viewMode == HistoryViewModes.COMPARE
if updates? and updates.length > 0
@_selectDocFromUpdates()
@reloadDiff()
@$scope.$watch "history.selection.pathname", () =>
@$scope.$watch "history.selection.pathname", (pathname) =>
if @$scope.history.viewMode == HistoryViewModes.POINT_IN_TIME
if pathname?
@loadFileAtPointInTime()
else
@reloadDiff()
show: () ->
@$scope.ui.view = "history"
@reset()
@$scope.history.viewMode = HistoryViewModes.POINT_IN_TIME
hide: () ->
@$scope.ui.view = "editor"
@ -35,6 +56,7 @@ define [
@$scope.history = {
isV2: true
updates: []
viewMode: null
nextBeforeTimestamp: null
atEnd: false
selection: {
@ -46,16 +68,33 @@ define [
toV: null
}
}
diff: null
files: []
diff: null # When history.viewMode == HistoryViewModes.COMPARE
selectedFile: null # When history.viewMode == HistoryViewModes.POINT_IN_TIME
}
restoreFile: (version, pathname) ->
url = "/project/#{@$scope.project_id}/restore_file"
@ide.$http.post(url, {
version, pathname,
_csrf: window.csrfToken
})
loadFileTreeForUpdate: (update) ->
{fromV, toV} = update
url = "/project/#{@$scope.project_id}/filetree/diff"
query = [ "from=#{toV}", "to=#{toV}" ]
url += "?" + query.join("&")
@$scope.history.loadingFileTree = true
@$scope.history.selectedFile = null
@$scope.history.selection.pathname = null
@ide.$http
.get(url)
.then (response) =>
@$scope.history.files = response.data.diff
@$scope.history.loadingFileTree = false
MAX_RECENT_UPDATES_TO_SELECT: 5
autoSelectRecentUpdates: () ->
return if @$scope.history.updates.length == 0
@ -70,12 +109,28 @@ define [
@$scope.history.updates[indexOfLastUpdateNotByMe].selectedFrom = true
autoSelectLastUpdate: () ->
return if @$scope.history.updates.length == 0
@selectUpdate @$scope.history.updates[0]
selectUpdate: (update) ->
selectedUpdateIndex = @$scope.history.updates.indexOf update
if selectedUpdateIndex == -1
selectedUpdateIndex = 0
for update in @$scope.history.updates
update.selectedTo = false
update.selectedFrom = false
@$scope.history.updates[selectedUpdateIndex].selectedTo = true
@$scope.history.updates[selectedUpdateIndex].selectedFrom = true
@loadFileTreeForUpdate @$scope.history.updates[selectedUpdateIndex]
BATCH_SIZE: 10
fetchNextBatchOfUpdates: () ->
url = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}"
if @$scope.history.nextBeforeTimestamp?
url += "&before=#{@$scope.history.nextBeforeTimestamp}"
@$scope.history.loading = true
@$scope.history.loadingFileTree = true
@ide.$http
.get(url)
.then (response) =>
@ -86,6 +141,23 @@ define [
@$scope.history.atEnd = true
@$scope.history.loading = false
loadFileAtPointInTime: () ->
pathname = @$scope.history.selection.pathname
toV = @$scope.history.selection.updates[0].toV
url = "/project/#{@$scope.project_id}/diff"
query = ["pathname=#{encodeURIComponent(pathname)}", "from=#{toV}", "to=#{toV}"]
url += "?" + query.join("&")
@$scope.history.selectedFile =
loading: true
@ide.$http
.get(url)
.then (response) =>
{text, binary} = @_parseDiff(response.data.diff)
@$scope.history.selectedFile.binary = binary
@$scope.history.selectedFile.text = text
@$scope.history.selectedFile.loading = false
.catch () ->
reloadDiff: () ->
diff = @$scope.history.diff
{updates} = @$scope.history.selection
@ -200,7 +272,11 @@ define [
@$scope.history.updates =
@$scope.history.updates.concat(updates)
@autoSelectRecentUpdates() if firstLoad
if firstLoad
if @$scope.history.viewMode == HistoryViewModes.COMPARE
@autoSelectRecentUpdates()
else
@autoSelectLastUpdate()
_perDocSummaryOfUpdates: (updates) ->
# Track current_pathname -> original_pathname

View file

@ -0,0 +1,19 @@
define [
"base"
], (App) ->
historyEntriesListController = ($scope, $element, $attrs) ->
ctrl = @
return
App.component "historyEntriesList", {
bindings:
entries: "<"
loadEntries: "&"
loadDisabled: "<"
loadInitialize: "<"
isLoading: "<"
currentUser: "<"
onEntrySelect: "&"
controller: historyEntriesListController
templateUrl: "historyEntriesListTpl"
}

View file

@ -0,0 +1,27 @@
define [
"base"
"ide/history/util/displayNameForUser"
], (App, displayNameForUser) ->
historyEntryController = ($scope, $element, $attrs) ->
ctrl = @
ctrl.displayName = displayNameForUser
ctrl.getProjectOpDoc = (projectOp) ->
if projectOp.rename? then "#{ projectOp.rename.pathname}#{ projectOp.rename.newPathname }"
else if projectOp.add? then "#{ projectOp.add.pathname}"
else if projectOp.remove? then "#{ projectOp.remove.pathname}"
ctrl.getUserCSSStyle = (user) ->
hue = user?.hue or 100
if ctrl.entry.inSelection
color : "#FFF"
else
color: "hsl(#{ hue }, 70%, 50%)"
return
App.component "historyEntry", {
bindings:
entry: "<"
currentUser: "<"
onSelect: "&"
controller: historyEntryController
templateUrl: "historyEntryTpl"
}

View file

@ -0,0 +1,34 @@
define [
"base"
"ide/file-tree/util/iconTypeFromName"
], (App, iconTypeFromName) ->
# TODO Add arrows in folders
historyFileEntityController = ($scope, $element, $attrs) ->
ctrl = @
_handleFolderClick = () ->
ctrl.isOpen = !ctrl.isOpen
ctrl.iconClass = _getFolderIcon()
_handleFileClick = () ->
ctrl.historyFileTreeController.handleEntityClick ctrl.fileEntity
_getFolderIcon = () ->
if ctrl.isOpen then "fa-folder-open" else "fa-folder"
ctrl.$onInit = () ->
if ctrl.fileEntity.type == "folder"
ctrl.isOpen = true
ctrl.iconClass = _getFolderIcon()
ctrl.handleClick = _handleFolderClick
else
ctrl.iconClass = "fa-#{ iconTypeFromName(ctrl.fileEntity.name) }"
ctrl.handleClick = _handleFileClick
$scope.$watch (() -> ctrl.historyFileTreeController.selectedPathname), (newPathname) ->
ctrl.isSelected = ctrl.fileEntity.pathname == newPathname
return
App.component "historyFileEntity", {
require:
historyFileTreeController: "^historyFileTree"
bindings:
fileEntity: "<"
controller: historyFileEntityController
templateUrl: "historyFileEntityTpl"
}

View file

@ -0,0 +1,18 @@
define [
"base"
], (App) ->
historyFileTreeController = ($scope, $element, $attrs) ->
ctrl = @
ctrl.handleEntityClick = (file) ->
ctrl.onSelectedFileChange file: file
return
App.component "historyFileTree", {
bindings:
fileTree: "<"
selectedPathname: "<"
onSelectedFileChange: "&"
isLoading: "<"
controller: historyFileTreeController
templateUrl: "historyFileTreeTpl"
}

View file

@ -1,7 +1,7 @@
define [
"base"
], (App) ->
App.controller "HistoryV2DiffController", ($scope, ide, event_tracking) ->
App.controller "HistoryV2DiffController", ($scope, ide, event_tracking, waitFor) ->
$scope.restoreState =
inflight: false
error: false
@ -24,17 +24,16 @@ define [
$scope.restoreState.inflight = false
openEntity = (data) ->
iterations = 0
{id, type} = data
do tryOpen = () ->
if iterations > 5
return
iterations += 1
entity = ide.fileTreeManager.findEntityById(id)
if entity? and type == 'doc'
waitFor(
() ->
ide.fileTreeManager.findEntityById(id)
3000
)
.then (entity) ->
if type == 'doc'
ide.editorManager.openDoc(entity)
else if entity? and type == 'file'
else if type == 'file'
ide.binaryFilesManager.openFile(entity)
else
setTimeout(tryOpen, 500)
.catch (err) ->
console.warn(err)

View file

@ -0,0 +1,54 @@
define [
"base"
], (App) ->
App.controller "HistoryV2FileTreeController", ["$scope", "ide", "_", ($scope, ide, _) ->
_previouslySelectedPathname = null
$scope.currentFileTree = []
_pathnameExistsInFiles = (pathname, files) ->
_.any files, (file) -> file.pathname == pathname
_getSelectedDefaultPathname = (files) ->
selectedPathname = null
if _previouslySelectedPathname? and _pathnameExistsInFiles _previouslySelectedPathname, files
selectedPathname = _previouslySelectedPathname
else
mainFile = _.find files, (file) -> /main\.tex$/.test file.pathname
if mainFile?
selectedPathname = _previouslySelectedPathname = mainFile.pathname
else
selectedPathname = _previouslySelectedPathname = files[0].pathname
return selectedPathname
$scope.handleFileSelection = (file) ->
$scope.history.selection.pathname = _previouslySelectedPathname = file.pathname
$scope.$watch 'history.files', (files) ->
if files? and files.length > 0
$scope.currentFileTree = _.reduce files, _reducePathsToTree, []
$scope.history.selection.pathname = _getSelectedDefaultPathname(files)
_reducePathsToTree = (currentFileTree, fileObject) ->
filePathParts = fileObject.pathname.split "/"
currentFileTreeLocation = currentFileTree
for pathPart, index in filePathParts
isFile = index == filePathParts.length - 1
if isFile
fileTreeEntity =
name: pathPart
pathname: fileObject.pathname
type: "file"
operation: fileObject.operation || "edited"
currentFileTreeLocation.push fileTreeEntity
else
fileTreeEntity = _.find currentFileTreeLocation, (entity) => entity.name == pathPart
if !fileTreeEntity?
fileTreeEntity =
name: pathPart
type: "folder"
children: []
currentFileTreeLocation.push fileTreeEntity
currentFileTreeLocation = fileTreeEntity.children
return currentFileTree
]

View file

@ -0,0 +1,76 @@
define [
"base",
"ide/history/util/displayNameForUser"
], (App, displayNameForUser) ->
App.controller "HistoryV2ListController", ["$scope", "ide", ($scope, ide) ->
$scope.hoveringOverListSelectors = false
$scope.loadMore = () =>
ide.historyManager.fetchNextBatchOfUpdates()
$scope.handleEntrySelect = (entry) ->
# $scope.$applyAsync () ->
ide.historyManager.selectUpdate(entry)
$scope.recalculateSelectedUpdates()
$scope.recalculateSelectedUpdates = () ->
beforeSelection = true
afterSelection = false
$scope.history.selection.updates = []
for update in $scope.history.updates
if update.selectedTo
inSelection = true
beforeSelection = false
update.beforeSelection = beforeSelection
update.inSelection = inSelection
update.afterSelection = afterSelection
if inSelection
$scope.history.selection.updates.push update
if update.selectedFrom
inSelection = false
afterSelection = true
$scope.recalculateHoveredUpdates = () ->
hoverSelectedFrom = false
hoverSelectedTo = false
for update in $scope.history.updates
# Figure out whether the to or from selector is hovered over
if update.hoverSelectedFrom
hoverSelectedFrom = true
if update.hoverSelectedTo
hoverSelectedTo = true
if hoverSelectedFrom
# We want to 'hover select' everything between hoverSelectedFrom and selectedTo
inHoverSelection = false
for update in $scope.history.updates
if update.selectedTo
update.hoverSelectedTo = true
inHoverSelection = true
update.inHoverSelection = inHoverSelection
if update.hoverSelectedFrom
inHoverSelection = false
if hoverSelectedTo
# We want to 'hover select' everything between hoverSelectedTo and selectedFrom
inHoverSelection = false
for update in $scope.history.updates
if update.hoverSelectedTo
inHoverSelection = true
update.inHoverSelection = inHoverSelection
if update.selectedFrom
update.hoverSelectedFrom = true
inHoverSelection = false
$scope.resetHoverState = () ->
for update in $scope.history.updates
delete update.hoverSelectedFrom
delete update.hoverSelectedTo
delete update.inHoverSelection
$scope.$watch "history.updates.length", () ->
$scope.recalculateSelectedUpdates()
]

View file

@ -0,0 +1,4 @@
define [], () ->
HistoryViewModes =
POINT_IN_TIME : 'point_in_time'
COMPARE : 'compare'

View file

@ -0,0 +1,16 @@
define [
"base"
"ace/ace"
], (App) ->
App.controller "TestControlsController", ($scope) ->
$scope.openProjectLinkedFileModal = () ->
window.openProjectLinkedFileModal()
$scope.openLinkedFileModal = () ->
window.openLinkedFileModal()
$scope.richText = () ->
current = window.location.toString()
target = "#{current}#{if window.location.search then '&' else '?'}rt=true"
window.location.href = target

View file

@ -0,0 +1,3 @@
define [
"ide/test-controls/controllers/TestControlsController"
], () ->

View file

@ -1,79 +1,7 @@
define [
"base"
"libs/platform"
"services/algolia-search"
], (App, platform) ->
App.controller 'ContactModal', ($scope, $modal) ->
$scope.contactUsModal = () ->
modalInstance = $modal.open(
templateUrl: "supportModalTemplate"
controller: "SupportModalController"
)
App.controller 'SupportModalController', ($scope, $modalInstance, algoliaSearch, event_tracking) ->
$scope.form = {}
$scope.sent = false
$scope.sending = false
$scope.suggestions = [];
_handleSearchResults = (success, results) ->
suggestions = for hit in results.hits
page_underscored = hit.pageName.replace(/\s/g,'_')
suggestion =
url :"/learn/kb/#{page_underscored}"
name : hit._highlightResult.pageName.value
event_tracking.sendMB "contact-form-suggestions-shown" if results.hits.length
$scope.$applyAsync () ->
$scope.suggestions = suggestions
$scope.contactUs = ->
if !$scope.form.email? or $scope.form.email == ""
console.log "email not set"
return
$scope.sending = true
ticketNumber = Math.floor((1 + Math.random()) * 0x10000).toString(32)
message = $scope.form.message
if $scope.form.project_url?
message = "#{message}\n\n project_url = #{$scope.form.project_url}"
params =
email: $scope.form.email
message: message or ""
subject: $scope.form.subject + " - [#{ticketNumber}]"
labels: "support"
about: "<div>browser: #{platform?.name} #{platform?.version}</div>
<div>os: #{platform?.os?.family} #{platform?.os?.version}</div>"
Groove.createTicket params, (response)->
$scope.sending = false
if response.responseText == "" # Blocked request or similar
$scope.error = true
else
data = JSON.parse(response.responseText)
if data.errors?
$scope.error = true
else
$scope.sent = true
$scope.$apply()
$scope.$watch "form.subject", (newVal, oldVal) ->
if newVal and newVal != oldVal and newVal.length > 3
algoliaSearch.searchKB newVal, _handleSearchResults, {
hitsPerPage: 3
typoTolerance: 'strict'
}
else
$scope.suggestions = [];
$scope.clickSuggestionLink = (url) ->
event_tracking.sendMB "contact-form-suggestions-clicked", { url }
$scope.close = () ->
$modalInstance.close()
App.controller 'UniverstiesContactController', ($scope, $modal, $http) ->
$scope.form = {}

View file

@ -50,6 +50,10 @@ define [
send: (category, action, label, value)->
ga('send', 'event', category, action, label, value)
sendGAOnce: (category, action, label, value) ->
if ! _eventInCache(action)
_addEventToCache(action)
@send category, action, label, value
editingSessionHeartbeat: () ->
return unless nextHeartbeat <= new Date()
@ -86,6 +90,9 @@ define [
if ! _eventInCache(key)
_addEventToCache(key)
@sendMB key, segmentation
eventInCache: (key) ->
_eventInCache(key)
}

View file

@ -9,6 +9,7 @@ define [
$scope.currencyCode = MultiCurrencyPricing.currencyCode
$scope.plans = MultiCurrencyPricing.plans
$scope.planCode = window.plan_code
$scope.switchToStudent = ()->
currentPlanCode = window.plan_code
@ -234,3 +235,6 @@ define [
{code:'WK',name:'Wake Island'},{code:'WF',name:'Wallis and Futuna'},{code:'EH',name:'Western Sahara'},{code:'YE',name:'Yemen'},
{code:'ZM',name:'Zambia'},{code:'AX',name:'&angst;land Islandscode:'}
]
sixpack.participate 'plans', ['default', 'more-details'], (chosenVariation, rawResponse)->
$scope.plansVariant = chosenVariation

View file

@ -3,7 +3,6 @@ define [
"libs/recurly-4.8.5"
], (App, recurly) ->
App.factory "MultiCurrencyPricing", () ->
currencyCode = window.recomendedCurrency
@ -146,16 +145,15 @@ define [
}
App.controller "PlansController", ($scope, $modal, event_tracking, abTestManager, MultiCurrencyPricing, $http, sixpack) ->
App.controller "PlansController", ($scope, $modal, event_tracking, abTestManager, MultiCurrencyPricing, $http, sixpack, $filter) ->
$scope.showPlans = false
$scope.plansVariant = 'default'
$scope.shouldABTestPlans = window.shouldABTestPlans
if $scope.shouldABTestPlans
$scope.showPlans = true
else
sixpack.participate 'plans-details', ['default', 'more-details'], (chosenVariation, rawResponse)->
$scope.plansVariant = chosenVariation
$scope.showPlans = true
$scope.plans = MultiCurrencyPricing.plans
@ -169,44 +167,57 @@ define [
$scope.ui =
view: "monthly"
$scope.changeCurreny = (newCurrency)->
$scope.changeCurreny = (e, newCurrency)->
e.preventDefault()
$scope.currencyCode = newCurrency
# because ternary logic in angular bindings is hard
$scope.getCollaboratorPlanCode = () ->
view = $scope.ui.view
variant = $scope.plansVariant
if view == "annual"
if variant == "default"
return "collaborator-annual"
else
return "collaborator-annual_#{variant}"
else
if variant == "default"
return "collaborator#{$scope.planQueryString}"
else
return "collaborator_#{variant}"
$scope.signUpNowClicked = (plan, annual)->
event_tracking.sendMB 'plans-page-start-trial', {plan}
$scope.signUpNowClicked = (plan, location)->
if $scope.ui.view == "annual"
plan = "#{plan}_annual"
plan = eventLabel(plan, location)
event_tracking.sendMB 'plans-page-start-trial', {plan}
event_tracking.send 'subscription-funnel', 'sign_up_now_button', plan
if $scope.shouldABTestPlans
sixpack.convert 'plans-details'
$scope.switchToMonthly = ->
$scope.ui.view = "monthly"
event_tracking.send 'subscription-funnel', 'plans-page', 'monthly-prices'
$scope.switchToMonthly = (e, location) ->
uiView = 'monthly'
switchEvent(e, uiView + '-prices', location)
$scope.ui.view = uiView
$scope.switchToStudent = ->
$scope.ui.view = "student"
event_tracking.send 'subscription-funnel', 'plans-page', 'student-prices'
$scope.switchToStudent = (e, location) ->
uiView = 'student'
switchEvent(e, uiView + '-prices', location)
$scope.ui.view = uiView
$scope.switchToAnnual = ->
$scope.ui.view = "annual"
event_tracking.send 'subscription-funnel', 'plans-page', 'annual-prices'
$scope.switchToAnnual = (e, location) ->
uiView = 'annual'
switchEvent(e, uiView + '-prices', location)
$scope.ui.view = uiView
$scope.openGroupPlanModal = () ->
$modal.open {
templateUrl: "groupPlanModalTemplate"
}
event_tracking.send 'subscription-funnel', 'plans-page', 'group-inquiry-potential'
eventLabel = (label, location) ->
if location && $scope.plansVariant != 'default'
label = label + '-' + location
if $scope.plansVariant != 'default'
label += '-exp-' + $scope.plansVariant
label
switchEvent = (e, label, location) ->
e.preventDefault()
gaLabel = eventLabel(label, location)
event_tracking.send 'subscription-funnel', 'plans-page', gaLabel

View file

@ -320,6 +320,9 @@ define [
name: cloneName
id: data.project_id
accessLevel: "owner"
owner: {
_id: user_id
}
# TODO: Check access level if correct after adding it in
# to the rest of the app
}
@ -350,14 +353,15 @@ define [
$scope.archiveOrLeaveSelectedProjects()
$scope.archiveOrLeaveSelectedProjects = () ->
selected_projects = $scope.getSelectedProjects()
selected_project_ids = $scope.getSelectedProjectIds()
$scope.archiveOrLeaveProjects($scope.getSelectedProjects())
$scope.archiveOrLeaveProjects = (projects) ->
projectIds = projects.map (p) -> p.id
# Remove project from any tags
for tag in $scope.tags
$scope._removeProjectIdsFromTagArray(tag, selected_project_ids)
$scope._removeProjectIdsFromTagArray(tag, projectIds)
for project in selected_projects
for project in projects
project.tags = []
if project.accessLevel == "owner"
project.archived = true
@ -414,16 +418,17 @@ define [
$scope.updateVisibleProjects()
$scope.restoreSelectedProjects = () ->
selected_projects = $scope.getSelectedProjects()
selected_project_ids = $scope.getSelectedProjectIds()
$scope.restoreProjects($scope.getSelectedProjects())
for project in selected_projects
$scope.restoreProjects = (projects) ->
projectIds = projects.map (p) -> p.id
for project in projects
project.archived = false
for project_id in selected_project_ids
for projectId in projectIds
queuedHttp {
method: "POST"
url: "/project/#{project_id}/restore"
url: "/project/#{projectId}/restore"
headers:
"X-CSRF-Token": window.csrfToken
}
@ -437,13 +442,14 @@ define [
)
$scope.downloadSelectedProjects = () ->
selected_project_ids = $scope.getSelectedProjectIds()
event_tracking.send 'project-list-page-interaction', 'project action', 'Download Zip'
if selected_project_ids.length > 1
path = "/project/download/zip?project_ids=#{selected_project_ids.join(',')}"
else
path = "/project/#{selected_project_ids[0]}/download/zip"
$scope.downloadProjectsById($scope.getSelectedProjectIds())
$scope.downloadProjectsById = (projectIds) ->
event_tracking.send 'project-list-page-interaction', 'project action', 'Download Zip'
if projectIds.length > 1
path = "/project/download/zip?project_ids=#{projectIds.join(',')}"
else
path = "/project/#{projectIds[0]}/download/zip"
window.location = path
$scope.openV1ImportModal = (project) ->
@ -487,6 +493,25 @@ define [
else
return "None"
$scope.isOwner = () ->
window.user_id == $scope.project.owner._id
$scope.$watch "project.selected", (value) ->
if value?
$scope.updateSelectedProjects()
$scope.clone = (e) ->
e.stopPropagation()
$scope.cloneProject($scope.project, "#{$scope.project.name} (Copy)")
$scope.download = (e) ->
e.stopPropagation()
$scope.downloadProjectsById([$scope.project.id])
$scope.archiveOrLeave = (e) ->
e.stopPropagation()
$scope.archiveOrLeaveProjects([$scope.project])
$scope.restore = (e) ->
e.stopPropagation()
$scope.restoreProjects([$scope.project])

View file

@ -8,7 +8,7 @@ define [
kbIdx = client.initIndex(window.sharelatex.algolia?.indexes?.kb)
service =
searchWiki: wikiIdx.search.bind(wikiIdx)
searchKB: kbIdx.search.bind(kbIdx)
searchWiki: if wikiIdx then wikiIdx.search.bind(wikiIdx) else null
searchKB: if kbIdx then kbIdx.search.bind(kbIdx) else null
return service

View file

@ -0,0 +1,20 @@
define [
"base"
], (App) ->
App.factory "waitFor", ($q) ->
waitFor = (testFunction, timeout, pollInterval=500) ->
iterationLimit = Math.floor(timeout / pollInterval)
iterations = 0
$q(
(resolve, reject) ->
do tryIteration = () ->
if iterations > iterationLimit
return reject(new Error("waiting too long, #{JSON.stringify({timeout, pollInterval})}"))
iterations += 1
result = testFunction()
if result?
resolve(result)
else
setTimeout(tryIteration, pollInterval)
)
return waitFor

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -80,6 +80,7 @@
@import "app/review-features-page.less";
@import "app/error-pages.less";
@import "app/v1-badge.less";
@import "app/editor/history-v2.less";
@import "app/metrics.less";
// Vendor CSS

View file

@ -74,9 +74,13 @@
#ide-body {
.full-size;
top: 40px;
top: @ide-body-top-offset;
&.ide-history-open {
top: @ide-body-top-offset + @editor-toolbar-height;
}
}
#editor, #editor-rich-text {
.full-size;
}
@ -88,6 +92,7 @@
.toolbar-editor {
height: @editor-toolbar-height;
background-color: @editor-toolbar-bg;
overflow: hidden;
}
.loading-screen {

View file

@ -0,0 +1,498 @@
.history-toolbar {
display: flex;
align-items: center;
position: absolute;
width: 100%;
top: @ide-body-top-offset;
height: @editor-toolbar-height;
line-height: 1;
font-size: @font-size-small;
background-color: @history-toolbar-bg-color;
z-index: 1;
color: @history-toolbar-color;
padding-left: (@line-height-computed / 2);
}
.history-toolbar when (@is-overleaf = false) {
border-bottom: @toolbar-border-bottom;
}
.history-toolbar-time {
font-weight: bold;
}
.history-toolbar-btn {
.btn;
.btn-info;
.btn-xs;
padding-left: @padding-small-horizontal;
padding-right: @padding-small-horizontal;
margin-left: (@line-height-computed / 2);
}
.history-entries {
font-size: @history-base-font-size;
color: @history-base-color;
height: 100%;
background-color: @history-base-bg;
}
.history-entry-day {
display: block;
background-color: @history-entry-day-bg;
color: #FFF;
padding: 5px 10px;
line-height: 1;
}
.history-entry-details {
background-color: #FFF;
margin-bottom: 2px;
padding: 5px 10px;
cursor: pointer;
.history-entry-selected & {
background-color: @history-entry-selected-bg;
color: #FFF;
}
}
.history-entry-changes {
.list-unstyled;
margin-bottom: 3px;
}
.history-entry-change {
}
.history-entry-change-action {
margin-right: 0.5em;
}
.history-entry-change-doc {
color: @history-highlight-color;
font-weight: bold;
word-break: break-all;
.history-entry-selected & {
color: #FFF;
}
}
.history-entry-metadata {
}
.history-entry-metadata-time {
white-space: nowrap;
}
.history-entry-metadata-users {
display: inline;
padding: 0;
}
.history-entry-metadata-user {
display: inline;
&::after {
content: ', ';
}
&:last-of-type::after {
content: none;
}
}
.history-file-tree-inner {
.full-size;
overflow-y: auto;
background-color: @file-tree-bg;
.loading {
color: #FFF;
font-size: @history-base-font-size;
text-align: center;
font-family: @font-family-serif;
}
}
.history-file-tree-inner when (@is-overleaf = false) {
font-size: 0.8rem;
}
.history-file-entity-wrapper {
color: #FFF;
margin-left: (@line-height-computed / 2);
}
.history-file-entity-link {
display: block;
position: relative;
color: @file-tree-item-color;
line-height: @file-tree-line-height;
&:hover {
background-color: @file-tree-item-hover-bg;
color: @file-tree-item-color;
text-decoration: none;
}
&:focus {
color: @file-tree-item-color;
outline: none;
text-decoration: none;
}
&:hover when (@is-overleaf = true) {
.fake-full-width-bg(@file-tree-item-hover-bg);
}
}
.history-file-entity-link-selected {
background-color: @file-tree-item-selected-bg;
font-weight: bold;
padding-right: 32px;
color: #FFF;
.fake-full-width-bg(@file-tree-item-selected-bg);
&:hover {
background-color: @file-tree-item-hover-bg;
}
}
.history-file-entity-icon {
color: @file-tree-item-icon-color;
font-size: 14px;
margin-right: .5em;
.history-file-entity-link-selected & {
color: #FFF;
}
}
.history-file-entity-name {
display: block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-file-entity-link-selected when (@is-overleaf = false) {
color: @brand-primary;
&:hover,
&:focus {
color: @brand-primary;
}
.history-file-entity-icon {
color: @brand-primary;
}
}
// @changesListWidth: 250px;
// @changesListPadding: @line-height-computed / 2;
// @selector-padding-vertical: 10px;
// @selector-padding-horizontal: @line-height-computed / 2;
// @day-header-height: 24px;
// @range-bar-color: @link-color;
// @range-bar-selected-offset: 14px;
// #history {
// .upgrade-prompt {
// position: absolute;
// top: 0;
// bottom: 0;
// left: 0;
// right: 0;
// z-index: 100;
// background-color: rgba(128,128,128,0.4);
// .message {
// margin: auto;
// margin-top: 100px;
// padding: (@line-height-computed / 2) @line-height-computed;
// width: 400px;
// background-color: white;
// border-radius: 8px;
// }
// .message-wider {
// width: 650px;
// margin-top: 60px;
// padding: 0;
// }
// .message-header {
// .modal-header;
// }
// .message-body {
// .modal-body;
// }
// }
// .diff-panel {
// .full-size;
// margin-right: @changesListWidth;
// }
// .diff {
// .full-size;
// .toolbar {
// padding: 3px;
// .name {
// float: left;
// padding: 3px @line-height-computed / 4;
// display: inline-block;
// }
// }
// .diff-editor {
// .full-size;
// top: 40px;
// }
// .hide-ace-cursor {
// .ace_active-line, .ace_cursor-layer, .ace_gutter-active-line {
// display: none;
// }
// }
// .diff-deleted {
// padding: @line-height-computed;
// }
// .deleted-warning {
// background-color: @brand-danger;
// color: white;
// padding: @line-height-computed / 2;
// margin-right: @line-height-computed / 4;
// }
// &-binary {
// .alert {
// margin: @line-height-computed / 2;
// }
// }
// }
// aside.change-list {
// border-left: 1px solid @editor-border-color;
// height: 100%;
// width: @changesListWidth;
// position: absolute;
// right: 0;
// .loading {
// text-align: center;
// font-family: @font-family-serif;
// }
// ul {
// li.change {
// position: relative;
// user-select: none;
// -ms-user-select: none;
// -moz-user-select: none;
// -webkit-user-select: none;
// .day {
// background-color: #fafafa;
// border-bottom: 1px solid @editor-border-color;
// padding: 4px;
// font-weight: bold;
// text-align: center;
// height: @day-header-height;
// font-size: 14px;
// line-height: 1;
// }
// .selectors {
// input {
// margin: 0;
// }
// position: absolute;
// left: @selector-padding-horizontal;
// top: 0;
// bottom: 0;
// width: 24px;
// .selector-from {
// position: absolute;
// bottom: @selector-padding-vertical;
// left: 0;
// opacity: 0.8;
// }
// .selector-to {
// position: absolute;
// top: @selector-padding-vertical;
// left: 0;
// opacity: 0.8;
// }
// .range {
// position: absolute;
// left: 5px;
// width: 4px;
// top: 0;
// bottom: 0;
// }
// }
// .description {
// padding: (@line-height-computed / 4);
// padding-left: 38px;
// min-height: 38px;
// border-bottom: 1px solid @editor-border-color;
// cursor: pointer;
// &:hover {
// background-color: @gray-lightest;
// }
// }
// .users {
// .user {
// font-size: 0.8rem;
// color: @gray;
// text-transform: capitalize;
// position: relative;
// padding-left: 16px;
// .color-square {
// height: 12px;
// width: 12px;
// border-radius: 3px;
// position: absolute;
// left: 0;
// bottom: 3px;
// }
// .name {
// width: 94%;
// white-space: nowrap;
// overflow: hidden;
// text-overflow: ellipsis;
// }
// }
// }
// .time {
// float: right;
// color: @gray;
// display: inline-block;
// padding-right: (@line-height-computed / 2);
// font-size: 0.8rem;
// line-height: @line-height-computed;
// }
// .doc {
// font-size: 0.9rem;
// font-weight: bold;
// }
// .action {
// color: @gray;
// text-transform: uppercase;
// font-size: 0.7em;
// margin-bottom: -2px;
// margin-top: 2px;
// &-edited {
// margin-top: 0;
// }
// }
// }
// li.loading-changes, li.empty-message {
// padding: 6px;
// cursor: default;
// &:hover {
// background-color: inherit;
// }
// }
// li.selected {
// border-left: 4px solid @range-bar-color;
// .day {
// padding-left: 0;
// }
// .description {
// padding-left: 34px;
// }
// .selectors {
// left: @selector-padding-horizontal - 4px;
// .range {
// background-color: @range-bar-color;
// }
// }
// }
// li.selected-to {
// .selectors {
// .range {
// top: @range-bar-selected-offset;
// }
// .selector-to {
// opacity: 1;
// }
// }
// }
// li.selected-from {
// .selectors {
// .range {
// bottom: @range-bar-selected-offset;
// }
// .selector-from {
// opacity: 1;
// }
// }
// }
// li.first-in-day {
// .selectors {
// .selector-to {
// top: @day-header-height + @selector-padding-vertical;
// }
// }
// }
// li.first-in-day.selected-to {
// .selectors {
// .range {
// top: @day-header-height + @range-bar-selected-offset;
// }
// }
// }
// }
// ul.hover-state {
// li {
// .selectors {
// .range {
// background-color: transparent;
// top: 0;
// bottom: 0;
// }
// }
// }
// li.hover-selected {
// .selectors {
// .range {
// top: 0;
// background-color: @gray-light;
// }
// }
// }
// li.hover-selected-to {
// .selectors {
// .range {
// top: @range-bar-selected-offset;
// }
// .selector-to {
// opacity: 1;
// }
// }
// }
// li.hover-selected-from {
// .selectors {
// .range {
// bottom: @range-bar-selected-offset;
// }
// .selector-from {
// opacity: 1;
// }
// }
// }
// li.first-in-day.hover-selected-to {
// .selectors {
// .range {
// top: @day-header-height + @range-bar-selected-offset;
// }
// }
// }
// }
// }
// }
// .diff-deleted {
// padding-top: 15px;
// }
// .editor-dark {
// #history {
// aside.change-list {
// border-color: @editor-dark-toolbar-border-color;
// ul li.change {
// .day {
// background-color: darken(@editor-dark-background-color, 10%);
// border-bottom: 1px solid @editor-dark-toolbar-border-color;
// }
// .description {
// border-bottom: 1px solid @editor-dark-toolbar-border-color;
// &:hover {
// background-color: black;
// }
// }
// }
// }
// }
// }

View file

@ -40,7 +40,8 @@
}
}
.diff-panel {
.diff-panel,
.point-in-time-panel {
.full-size;
margin-right: @changesListWidth;
}
@ -49,6 +50,7 @@
.full-size;
.toolbar {
padding: 3px;
height: 32px;
.name {
float: left;
padding: 3px @line-height-computed / 4;
@ -57,13 +59,9 @@
}
.diff-editor {
.full-size;
top: 40px;
}
.hide-ace-cursor {
.ace_active-line, .ace_cursor-layer, .ace_gutter-active-line {
display: none;
}
top: 32px;
}
.diff-deleted {
padding: @line-height-computed;
}
@ -90,6 +88,7 @@
.loading {
text-align: center;
font-family: @font-family-serif;
margin-top: (@line-height-computed / 2);
}
ul {
@ -305,6 +304,12 @@
padding-top: 15px;
}
.hide-ace-cursor {
.ace_active-line, .ace_cursor-layer, .ace_gutter-active-line {
display: none;
}
}
.editor-dark {
#history {
aside.change-list {

View file

@ -184,8 +184,12 @@
}
}
/**************************************
Toggle Switch
***************************************/
.toggle-wrapper {
width: 200px;
min-width: 200px;
height: 24px;
}
@ -241,3 +245,88 @@
transform: translate(100%);
border-radius: 0 @btn-border-radius-base @btn-border-radius-base 0;
}
/**************************************
Formatting buttons
***************************************/
.formatting-buttons {
width: 100%;
overflow: hidden;
}
.formatting-buttons-wrapper {
display: flex;
}
.formatting-btn {
color: @formatting-btn-color;
background-color: @formatting-btn-bg;
padding: 0;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
border: none;
border-left: 1px solid @formatting-btn-border;
border-radius: 0;
&:hover {
color: @formatting-btn-color;
}
}
.formatting-btn--icon {
min-width: 32px;
width: 32px;
}
.formatting-btn--icon:last-of-type {
border-right: 1px solid @formatting-btn-border;
}
.formatting-btn--more {
padding-left: 9px;
padding-right: 9px;
.caret {
margin-top: 1px;
}
}
.formatting-icon {
font-style: normal;
line-height: 1.5;
}
.formatting-icon--small {
font-size: small;
line-height: 1.9;
}
.formatting-icon--serif {
font-family: @font-family-serif;
}
.formatting-more {
margin-left: auto;
}
.formatting-menu {
min-width: auto;
max-width: 130px;
background-color: @formatting-menu-bg;
}
.formatting-menu-item {
float: left;
}
.formatting-menu-item > .formatting-btn {
border-right: none;
}
// Disable border on left-most icon in menu
.formatting-menu-item:nth-of-type(4n + 1) > .formatting-btn {
border-left: none;
}

View file

@ -63,6 +63,12 @@
}
.card .btn { white-space:normal; }
.top-switch {
.currency-dropdown {
margin-right: -15px;
}
}
}
#changePlanSection {
@ -127,4 +133,261 @@ input.paymentTypeOption.ng-valid {
text-align: right;
}
/**
Plans Test
*/
@best-val-height: 35px;
@highlight-border: 3px;
@highlight-color: #d3584b;
@gray-med: #6d6d6d;
@white-med: #fdfdfd;
.more-details {
.best-value {
color: @red;
line-height: @line-height-computed;
}
blockquote {
footer{
/* accessibility fix */
color: @gray-med;
}
}
.btn-header {
font-family: @font-family-sans-serif;
margin-left: 10px;
margin-top: -10px;
text-shadow: 0 0 0;
}
.card-first, .card-last {
background: @white-med;
}
.card-highlighted {
border: @highlight-border solid @gray-lighter;
padding-top: 10px!important;
.best-value {
margin-bottom: 15px;
}
.card-header {
padding-bottom: 22px; /* align hr with other plans */
}
}
.card-header {
margin-bottom: 15px;
}
.circle {
/* accessibility fix */
span.small {
color: rgba(255, 255, 255, 0.85)
}
}
.circle-img {
border-radius: 50%;
float: right;
height: 100px;
overflow: hidden;
position: relative;
width: 100px;
img {
display: inline;
margin: 0 auto;
width: 100%;
}
}
.faq:last-child {
p {
margin-bottom: 0;
}
}
.questions-header {
color: @red;
line-height: 37px;
margin: 0;
text-align: right;
}
.tagline {
margin-bottom: 20px;
}
/* Media Queries */
@media (max-width: @screen-md-min) {
.card-highlighted {
/*override style in cards.less */
margin-top: @line-height-computed!important;
}
.circle-img {
float: left;
margin: 0 15px;
}
}
@media (min-width: @screen-md-min) {
blockquote {
margin-bottom: 0;
}
.faq {
.row:nth-child(2) {
h3 {
margin-top: 0;
}
}
}
}
}
.student-disclaimer {
font-size: 14px; /* match .paymentPageFeatures p */
color: @gray; /* match .paymentPageFeatures p */
margin: 12.5px 0 0 0;
}
/**
Plans Table
*/
.plans-table {
border: 1px solid @gray-lighter;
background-color: @white-med;
margin: @best-val-height 0 15px 0;
table-layout: fixed;
width: 100%;
th, td {
-moz-background-clip: padding;
-webkit-background-clip: padding;
background-clip: padding-box; /* needed for firefox when there is bg color */
border: 1px solid @gray-lighter;
padding: 6px;
text-align: center;
vertical-align: middle;
}
td {
font-weight: bold;
}
th {
border-top: 0;
font-family: @headings-font-family;
font-size: @font-size-h2;
font-weight: @headings-font-weight;
line-height: @headings-line-height;
padding: 18px;
}
th:first-child, td:first-child {
border-left: 0;
}
th:last-child, td:last-child {
border-right: 0;
}
td:first-child {
font-weight: bold;
padding-left: 18px;
text-align: left;
}
tr:first-child {
th {
position: relative;
/* keep here position here, otherwise messes up border on safari */
}
}
tr:last-child {
td {
border-bottom: 0;
padding: 18px;
}
/* highlighted column */
td:nth-child(3) {
position: relative;
/* keep here position here, otherwise messes up border on safari when there is a bg color */
&:before {
/* needed for safafi */
border-top: 1px solid @gray-lighter;
content: '';
left: 0;
position: absolute;
top: -1px;
width: 100%;
}
}
td:first-child {
border: 0;
}
}
.fa-check {
color: @green;
}
/* accessibility fixes */
.small {
color: @gray-med;
}
/* highlighted column */
td:nth-child(3), th:nth-child(3) {
background-color: white;
border-left: @highlight-border solid @gray-lighter;
border-right: @highlight-border solid @gray-lighter;
}
.outer {
left: -@highlight-border;
right: -@highlight-border;
position: absolute;
.outer-content {
background: white;
border: @highlight-border solid @gray-lighter;
border-radius: @border-radius-base;
font-size: @font-size-base;
font-family: @font-family-sans-serif;
font-weight: bold;
height: @best-val-height;
padding-top: 10px;
}
}
.outer.outer-top {
top: -@best-val-height;
.outer-content {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-bottom: 0;
}
}
.outer.outer-btm {
bottom: -@best-val-height/2;
.outer-content {
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: 0;
height: @best-val-height/2;
}
}
/* highlight rows on hover */
tr:hover {
td {
background-color: @gray-lightest;
}
}
tr:first-child:hover {
background-color: transparent;
}
tr:last-child:hover {
background-color: transparent;
td {
background-color: transparent;
}
}
/* tooltip */
sup {
color: @red;
cursor: pointer;
margin-left: 5px;
}
.tooltip.in {
min-width: 200px
}
}

View file

@ -369,6 +369,16 @@ ul.project-list {
.v1-badge {
margin-left: -4px;
}
.action-btn-row-header, .action-btn-row {
padding-right: 20px;
text-align: right;
}
.action-btn {
padding: 0 0.3em;
margin-left: 0.2em;
}
}
i.tablesort {
padding-left: 8px;

View file

@ -145,9 +145,14 @@ output {
opacity: 1; // iOS fix for unreadable disabled content
}
// Reset height for `textarea`s
// Reset height for `textarea`s, and smaller border-radius
textarea& {
height: auto;
border-radius: @border-radius-base;
}
// Smaller border-radius for `select` inputs
select& {
border-radius: @border-radius-base;
}
}

View file

@ -888,6 +888,7 @@
@footer-padding : 2em;
// Editor header
@ide-body-top-offset : 40px;
@toolbar-header-bg-color : transparent;
@toolbar-header-shadow : 0 0 2px #ccc;
@toolbar-btn-color : @link-color;
@ -940,6 +941,12 @@
@toggle-switch-bg : @gray-lightest;
@toggle-switch-highlight-color : @brand-primary;
// Formatting buttons
@formatting-btn-color : @btn-default-color;
@formatting-btn-bg : @btn-default-bg;
@formatting-btn-border : @btn-default-border;
@formatting-menu-bg : @btn-default-bg;
// Chat
@chat-bg : transparent;
@chat-message-color : @text-color;
@ -973,3 +980,14 @@
@sys-msg-background : @state-warning-bg;
@sys-msg-color : #333;
@sys-msg-border : 1px solid @common-border-color;
// v2 History
@history-base-font-size : @font-size-small;
@history-base-bg : @gray-lightest;
@history-entry-day-bg : @gray;
@history-entry-selected-bg : @red;
@history-base-color : @gray-light;
@history-highlight-color : @gray;
@history-toolbar-bg-color : @toolbar-alt-bg-color;
@history-toolbar-color : @text-color;

View file

@ -244,6 +244,12 @@
@toggle-switch-radius-left : @btn-border-radius-base 0 0 @btn-border-radius-base;
@toggle-switch-radius-right : 0 @btn-border-radius-base @btn-border-radius-base 0;
// Formatting buttons
@formatting-btn-color : #FFF;
@formatting-btn-bg : @ol-blue-gray-5;
@formatting-btn-border : @ol-blue-gray-4;
@formatting-menu-bg : @ol-blue-gray-5;
// Chat
@chat-bg : @ol-blue-gray-5;
@chat-message-color : #FFF;
@ -265,6 +271,17 @@
@log-line-no-color : #FFF;
@log-hints-color : @ol-blue-gray-4;
// v2 History
@history-base-font-size : @font-size-small;
@history-base-bg : @ol-blue-gray-1;
@history-entry-day-bg : @ol-blue-gray-2;
@history-entry-selected-bg : @ol-green;
@history-base-color : @ol-blue-gray-2;
@history-highlight-color : @ol-type-color;
@history-toolbar-bg-color : @editor-toolbar-bg;
@history-toolbar-color : #FFF;
// System messages
@sys-msg-background : @ol-blue;
@sys-msg-color : #FFF;

View file

@ -161,4 +161,7 @@ hr {
margin-top: @line-height-computed / 2;
}
.row-spaced-large {
margin-top: @line-height-computed * 2;
}

Some files were not shown because too many files have changed in this diff Show more