Przeglądaj źródła

Merge pull request #900 from sharelatex/ja-show-last-modified

Record and show last modified by user for projects
James Allen 8 lat temu
rodzic
commit
078575b236

+ 9 - 0
services/web/app/coffee/Features/History/HistoryController.coffee

@@ -7,6 +7,7 @@ HistoryManager = require "./HistoryManager"
 ProjectDetailsHandler = require "../Project/ProjectDetailsHandler"
 ProjectDetailsHandler = require "../Project/ProjectDetailsHandler"
 ProjectEntityUpdateHandler = require "../Project/ProjectEntityUpdateHandler"
 ProjectEntityUpdateHandler = require "../Project/ProjectEntityUpdateHandler"
 RestoreManager = require "./RestoreManager"
 RestoreManager = require "./RestoreManager"
+ProjectUpdateHandler = require "../Project/ProjectUpdateHandler"
 
 
 module.exports = HistoryController =
 module.exports = HistoryController =
 	selectHistoryApi: (req, res, next = (error) ->) ->
 	selectHistoryApi: (req, res, next = (error) ->) ->
@@ -143,3 +144,11 @@ module.exports = HistoryController =
 				error = new Error("history api responded with non-success code: #{response.statusCode}")
 				error = new Error("history api responded with non-success code: #{response.statusCode}")
 				logger.error err: error, "project-history api responded with non-success code: #{response.statusCode}"
 				logger.error err: error, "project-history api responded with non-success code: #{response.statusCode}"
 				callback(error)
 				callback(error)
+
+	setLastUpdated: (req, res, next) ->
+		{project_id} = req.params
+		{user_id, timestamp} = req.body
+		logger.log {project_id, user_id, timestamp}, 'updating last updated date'
+		ProjectUpdateHandler.markAsUpdated project_id, user_id, timestamp, (error) ->
+			return next(error) if error?
+			res.sendStatus 200

+ 6 - 1
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -184,7 +184,7 @@ module.exports = ProjectController =
 			notifications: (cb)->
 			notifications: (cb)->
 				NotificationsHandler.getUserNotifications user_id, cb
 				NotificationsHandler.getUserNotifications user_id, cb
 			projects: (cb)->
 			projects: (cb)->
-				ProjectGetter.findAllUsersProjects user_id, 'name lastUpdated publicAccesLevel archived owner_ref tokens', cb
+				ProjectGetter.findAllUsersProjects user_id, 'name lastUpdated lastUpdatedBy publicAccesLevel archived owner_ref tokens', cb
 			v1Projects: (cb) ->
 			v1Projects: (cb) ->
 				Modules.hooks.fire "findAllV1Projects", user_id, (error, projects = []) ->
 				Modules.hooks.fire "findAllV1Projects", user_id, (error, projects = []) ->
 					if error? and error instanceof V1ConnectionError
 					if error? and error instanceof V1ConnectionError
@@ -392,6 +392,7 @@ module.exports = ProjectController =
 			id: project._id
 			id: project._id
 			name: project.name
 			name: project.name
 			lastUpdated: project.lastUpdated
 			lastUpdated: project.lastUpdated
+			lastUpdatedBy: project.lastUpdatedBy
 			publicAccessLevel: project.publicAccesLevel
 			publicAccessLevel: project.publicAccesLevel
 			accessLevel: accessLevel
 			accessLevel: accessLevel
 			source: source
 			source: source
@@ -430,6 +431,8 @@ module.exports = ProjectController =
 		for project in projects
 		for project in projects
 			if project.owner_ref?
 			if project.owner_ref?
 				users[project.owner_ref.toString()] = true
 				users[project.owner_ref.toString()] = true
+			if project.lastUpdatedBy?
+				users[project.lastUpdatedBy] = true
 
 
 		jobs = []
 		jobs = []
 		for user_id, _ of users
 		for user_id, _ of users
@@ -444,6 +447,8 @@ module.exports = ProjectController =
 			for project in projects
 			for project in projects
 				if project.owner_ref?
 				if project.owner_ref?
 					project.owner = users[project.owner_ref.toString()]
 					project.owner = users[project.owner_ref.toString()]
+				if project.lastUpdatedBy?
+					project.lastUpdatedBy = users[project.lastUpdatedBy.toString()]
 			callback null, projects
 			callback null, projects
 
 
 	_buildWarningsList: (v1ProjectData = {}) ->
 	_buildWarningsList: (v1ProjectData = {}) ->

+ 0 - 2
services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee

@@ -114,8 +114,6 @@ module.exports = ProjectEntityUpdateHandler = self =
 					logger.log {project_id, doc_id, modified}, "finished updating doc lines"
 					logger.log {project_id, doc_id, modified}, "finished updating doc lines"
 					# path will only be present if the doc is not deleted
 					# path will only be present if the doc is not deleted
 					if modified && !isDeletedDoc
 					if modified && !isDeletedDoc
-						# Don't need to block for marking as updated
-						ProjectUpdateHandler.markAsUpdated project_id
 						TpdsUpdateSender.addDoc {project_id:project_id, path:path.fileSystem, doc_id:doc_id, project_name:project.name, rev:rev}, callback
 						TpdsUpdateSender.addDoc {project_id:project_id, path:path.fileSystem, doc_id:doc_id, project_name:project.name, rev:rev}, callback
 					else
 					else
 						callback()
 						callback()

+ 9 - 14
services/web/app/coffee/Features/Project/ProjectUpdateHandler.coffee

@@ -2,30 +2,25 @@ Project = require('../../models/Project').Project
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')
 
 
 module.exports = 
 module.exports = 
-	markAsUpdated : (project_id, callback)->
+	markAsUpdated : (project_id, user_id, timestamp, callback)->
 		conditions = {_id:project_id}
 		conditions = {_id:project_id}
-		update = {lastUpdated:Date.now()}
-		Project.update conditions, update, {}, (err)->
-			if callback?
-				callback()
+		update = {
+			lastUpdated: new Date(timestamp),
+			lastUpdatedBy: user_id
+		}
+		Project.update conditions, update, {}, callback
 
 
 	markAsOpened : (project_id, callback)->
 	markAsOpened : (project_id, callback)->
 		conditions = {_id:project_id}
 		conditions = {_id:project_id}
 		update = {lastOpened:Date.now()}
 		update = {lastOpened:Date.now()}
-		Project.update conditions, update, {}, (err)->
-			if callback?
-				callback()
+		Project.update conditions, update, {}, callback
 
 
 	markAsInactive: (project_id, callback)->
 	markAsInactive: (project_id, callback)->
 		conditions = {_id:project_id}
 		conditions = {_id:project_id}
 		update = {active:false}
 		update = {active:false}
-		Project.update conditions, update, {}, (err)->
-			if callback?
-				callback()
+		Project.update conditions, update, {}, callback
 
 
 	markAsActive: (project_id, callback)->
 	markAsActive: (project_id, callback)->
 		conditions = {_id:project_id}
 		conditions = {_id:project_id}
 		update = {active:true}
 		update = {active:true}
-		Project.update conditions, update, {}, (err)->
-			if callback?
-				callback()
+		Project.update conditions, update, {}, callback

+ 1 - 0
services/web/app/coffee/models/Project.coffee

@@ -19,6 +19,7 @@ DeletedFileSchema = new Schema
 ProjectSchema = new Schema
 ProjectSchema = new Schema
 	name              :   {type:String, default:'new project'}
 	name              :   {type:String, default:'new project'}
 	lastUpdated       :   {type:Date, default: () -> new Date()}
 	lastUpdated       :   {type:Date, default: () -> new Date()}
+	lastUpdatedBy     :   {type:ObjectId, ref: 'User'}
 	lastOpened        :   {type:Date}
 	lastOpened        :   {type:Date}
 	active            :   { type: Boolean,  default: true }
 	active            :   { type: Boolean,  default: true }
 	owner_ref         :   {type:ObjectId, ref:'User'}
 	owner_ref         :   {type:ObjectId, ref:'User'}

+ 1 - 0
services/web/app/coffee/router.coffee

@@ -238,6 +238,7 @@ module.exports = class Router
 		webRouter.post '/project/:project_id/doc/:doc_id/restore', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreDocFromDeletedDoc
 		webRouter.post '/project/:project_id/doc/:doc_id/restore', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreDocFromDeletedDoc
 		webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2
 		webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2
 		privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory
 		privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory
+		privateApiRouter.post "/project/:project_id/last_updated", AuthenticationController.httpAuth, HistoryController.setLastUpdated
 
 
 		webRouter.get "/project/:Project_id/labels", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.ensureProjectHistoryEnabled, HistoryController.getLabels
 		webRouter.get "/project/:Project_id/labels", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.ensureProjectHistoryEnabled, HistoryController.getLabels
 		webRouter.post "/project/:Project_id/labels", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.selectHistoryApi, HistoryController.ensureProjectHistoryEnabled, HistoryController.createLabel
 		webRouter.post "/project/:Project_id/labels", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.selectHistoryApi, HistoryController.ensureProjectHistoryEnabled, HistoryController.createLabel

+ 120 - 101
services/web/app/views/project/list/item.pug

@@ -1,104 +1,123 @@
-- 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"
+td.selectProject
+  input.select-item(
+    ng-if="!project.isV1Project",
+    select-individual,
+    type="checkbox",
+    ng-disabled="shouldDisableCheckbox(project)",
+    ng-model="project.selected"
+    stop-propagation="click"
+    aria-label=translate('select_project') + " '{{ project.name }}'"
+  )
+  span.v1-badge(
+    ng-if="project.isV1Project",
+    aria-label=translate("v1_badge")
+    tooltip-template="'v1ProjectTooltipTemplate'"
+    tooltip-append-to-body="true"
+  )
+td.projectName
+  span(ng-if="project.isV1Project")
+    if settings.overleaf && settings.overleaf.host
+      button.btn.btn-link.projectName(
+        ng-click="openV1ImportModal(project)"
+        stop-propagation="click"
+        ng-show="project.accessLevel == 'owner'"
+      ) {{project.name}}
+      a.projectName(
+        href=settings.overleaf.host + "/{{project.id}}"
+        target="_blank"
+        ng-hide="project.accessLevel == 'owner'"
+      ) {{project.name}}
+  span(ng-if="!project.isV1Project")
+    a.projectName(
+      ng-href="{{projectLink(project)}}"
+      stop-propagation="click"
+    ) {{project.name}}
+    span(
+      ng-controller="TagListController"
+    )
+      .tag-label(
+        ng-repeat='tag in project.tags'
+        stop-propagation="click"
+      )
+        a.label.label-default.tag-label-name(
+          href,
+          ng-click="selectTag(tag)"
+        ) {{tag.name}}
+        a.label.label-default.tag-label-remove(
+          href
+          ng-click="removeProjectFromTag(project, tag)"
+        ) ×
 
 
-div(class=titleClasses)
-	input.select-item(
-		select-individual,
-		type="checkbox",
-		ng-disabled="shouldDisableCheckbox(project)",
-		ng-model="project.selected"
-		stop-propagation="click"
-		aria-label=translate('select_project') + " '{{ project.name }}'"
-	)
-	span
-		a.projectName(
-			ng-href="{{projectLink(project)}}"
-			stop-propagation="click"
-		) {{project.name}}
-		span(
-			ng-controller="TagListController"
-		)
-			.tag-label(
-				ng-repeat='tag in project.tags'
-				stop-propagation="click"
-			)
-				a.label.label-default.tag-label-name(
-					href,
-					ng-click="selectTag(tag)"
-				) {{tag.name}}
-				a.label.label-default.tag-label-remove(
-					href
-					ng-click="removeProjectFromTag(project, tag)"
-				) ×
+td
+  span.owner {{userDisplayName(project.owner)}}
+  span(ng-if="isLinkSharingProject(project)")
+    |  
+    i.fa.fa-link.small(
+      tooltip=translate("link_sharing")
+      tooltip-placement="right"
+      tooltip-append-to-body="true"
+    )
 
 
-.col-xs-2
-	span.owner {{ownerName()}}
-	span(ng-if="isLinkSharingProject(project)")
-		|  
-		i.fa.fa-link.small(
-			tooltip=translate("link_sharing")
-			tooltip-placement="right"
-			tooltip-append-to-body="true"
-		)
+td
+  span.last-modified(tooltip="{{project.lastUpdated | formatDate}}")
+    | {{project.lastUpdated | fromNowDate}}
+  span(ng-if='project.lastUpdatedBy')
+    |
+    | #{translate('by')}
+    |
+    | {{userDisplayName(project.lastUpdatedBy)}}
 
 
-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
-		div(
-			ng-if="!project.isTableActionInflight"
-		)
-			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
-			button.btn.btn-link.action-btn(
-				ng-if="project.archived && isOwner()"
-				tooltip=translate('delete_forever'),
-				tooltip-placement="top",
-				tooltip-append-to-body="true",
-				ng-click="deleteProject($event)"
-			)
-				i.icon.fa.fa-trash
-		div(
-			ng-if="project.isTableActionInflight"
-		)
-			i.fa.fa-spinner.fa-spin
+td.text-right
+  div(
+    ng-if="!project.isTableActionInflight && !project.isV1Project"
+  )
+    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
+    button.btn.btn-link.action-btn(
+      ng-if="project.archived && isOwner()"
+      tooltip=translate('delete_forever'),
+      tooltip-placement="top",
+      tooltip-append-to-body="true",
+      ng-click="deleteProject($event)"
+    )
+      i.icon.fa.fa-trash
+  div(
+    ng-if="project.isTableActionInflight"
+  )
+    i.fa.fa-spinner.fa-spin

+ 160 - 170
services/web/app/views/project/list/project-list.pug

@@ -1,185 +1,175 @@
 .row
 .row
-	.col-xs-12(ng-cloak)
+  .col-xs-12(ng-cloak)
 
 
 
 
 
 
-		form.project-search.form-horizontal(role="form")
-			.form-group.has-feedback.has-feedback-left.col-md-7.col-xs-12
-				input.form-control.col-md-7.col-xs-12(
-					placeholder=translate('search_projects')+"…",
-					aria-label=translate('search_projects')+"…",
-					autofocus='autofocus',
-					ng-model="searchText.value",
-					focus-on='search:clear',
-					ng-keyup="searchProjects()"
-				)
-				i.fa.fa-search.form-control-feedback-left
-				i.fa.fa-times.form-control-feedback(
-					ng-click="clearSearchText()",
-					style="cursor: pointer;",
-					ng-show="searchText.value.length > 0"
-				)
-			//- i.fa.fa-remove
+    form.project-search.form-horizontal(role="form")
+      .form-group.has-feedback.has-feedback-left.col-md-7.col-xs-12
+        input.form-control.col-md-7.col-xs-12(
+          placeholder=translate('search_projects')+"…",
+          aria-label=translate('search_projects')+"…",
+          autofocus='autofocus',
+          ng-model="searchText.value",
+          focus-on='search:clear',
+          ng-keyup="searchProjects()"
+        )
+        i.fa.fa-search.form-control-feedback-left
+        i.fa.fa-times.form-control-feedback(
+          ng-click="clearSearchText()",
+          style="cursor: pointer;",
+          ng-show="searchText.value.length > 0"
+        )
+      //- i.fa.fa-remove
 
 
-		.project-tools(ng-cloak)
-			.btn-toolbar(ng-show="filter != 'archived'")
-				.btn-group(ng-hide="selectedProjects.length < 1")
-					a.btn.btn-default(
-						href,
-						tooltip=translate('download'),
-						tooltip-placement="bottom",
-						tooltip-append-to-body="true",
-						ng-click="downloadSelectedProjects()"
-					)
-						i.fa.fa-cloud-download
-					- var archiveButtonString = settings.overleaf ? translate("archive") : translate("delete")
-					- var archiveButtonIcon = settings.overleaf ? "fa-inbox" : "fa-trash-o"
-					a.btn.btn-default(
-						href,
-						tooltip=`{{ isArchiveableProjectSelected ? '${archiveButtonString}' : '${translate("leave")}' }}`,
-						tooltip-placement="bottom",
-						tooltip-append-to-body="true",
-						ng-click="openArchiveProjectsModal()"
-					)
-						i.fa(ng-class=`isArchiveableProjectSelected ? '${archiveButtonIcon}' : 'fa-sign-out'`)
+    .project-tools(ng-cloak)
+      .btn-toolbar(ng-show="filter != 'archived'")
+        .btn-group(ng-hide="selectedProjects.length < 1")
+          a.btn.btn-default(
+            href,
+            tooltip=translate('download'),
+            tooltip-placement="bottom",
+            tooltip-append-to-body="true",
+            ng-click="downloadSelectedProjects()"
+          )
+            i.fa.fa-cloud-download
+          - var archiveButtonString = settings.overleaf ? translate("archive") : translate("delete")
+          - var archiveButtonIcon = settings.overleaf ? "fa-inbox" : "fa-trash-o"
+          a.btn.btn-default(
+            href,
+            tooltip=`{{ isArchiveableProjectSelected ? '${archiveButtonString}' : '${translate("leave")}' }}`,
+            tooltip-placement="bottom",
+            tooltip-append-to-body="true",
+            ng-click="openArchiveProjectsModal()"
+          )
+            i.fa(ng-class=`isArchiveableProjectSelected ? '${archiveButtonIcon}' : 'fa-sign-out'`)
 
 
-				.btn-group.dropdown(ng-hide="selectedProjects.length < 1", dropdown)
-					a.btn.btn-default.dropdown-toggle(
-						href,
-						data-toggle="dropdown",
-						dropdown-toggle,
-						tooltip=translate('add_to_folders'),
-						tooltip-append-to-body="true",
-						tooltip-placement="bottom"
-					)
-						i.fa.fa-folder-open-o
-						|  
-						span.caret
-					ul.dropdown-menu.dropdown-menu-right.js-tags-dropdown-menu.tags-dropdown-menu(
-						role="menu"
-						ng-controller="TagListController"
-					)
-						li.dropdown-header #{translate("add_to_folder")}
-						li(
-							ng-repeat="tag in tags | orderBy:'name'",
-							ng-controller="TagDropdownItemController"
-							ng-if="!tag.isV1"
-						)
-							a(href="#", ng-click="addOrRemoveProjectsFromTag()", stop-propagation="click")
-								i.fa(
-									ng-class="{\
-										'fa-check-square-o': areSelectedProjectsInTag == true,\
-										'fa-square-o': areSelectedProjectsInTag == false,\
-										'fa-minus-square-o': areSelectedProjectsInTag == 'partial'\
-									}"
-								)
-								|  {{tag.name}}
-						li.divider
-						li
-							a(href, ng-click="openNewTagModal()", stop-propagation="click") #{translate("create_new_folder")}
+        .btn-group.dropdown(ng-hide="selectedProjects.length < 1", dropdown)
+          a.btn.btn-default.dropdown-toggle(
+            href,
+            data-toggle="dropdown",
+            dropdown-toggle,
+            tooltip=translate('add_to_folders'),
+            tooltip-append-to-body="true",
+            tooltip-placement="bottom"
+          )
+            i.fa.fa-folder-open-o
+            |
+            span.caret
+          ul.dropdown-menu.dropdown-menu-right.js-tags-dropdown-menu.tags-dropdown-menu(
+            role="menu"
+            ng-controller="TagListController"
+          )
+            li.dropdown-header #{translate("add_to_folder")}
+            li(
+              ng-repeat="tag in tags | orderBy:'name'",
+              ng-controller="TagDropdownItemController"
+              ng-if="!tag.isV1"
+            )
+              a(href="#", ng-click="addOrRemoveProjectsFromTag()", stop-propagation="click")
+                i.fa(
+                  ng-class="{\
+                    'fa-check-square-o': areSelectedProjectsInTag == true,\
+                    'fa-square-o': areSelectedProjectsInTag == false,\
+                    'fa-minus-square-o': areSelectedProjectsInTag == 'partial'\
+                  }"
+                )
+                |  {{tag.name}}
+            li.divider
+            li
+              a(href, ng-click="openNewTagModal()", stop-propagation="click") #{translate("create_new_folder")}
 
 
-				.btn-group(ng-hide="selectedProjects.length != 1", dropdown).dropdown
-					a.btn.btn-default.dropdown-toggle(
-						href,
-						data-toggle="dropdown",
-						dropdown-toggle
-					) #{translate("more")} 
-						span.caret
-					ul.dropdown-menu.dropdown-menu-right(role="menu")
-						li(ng-show="getFirstSelectedProject().accessLevel == 'owner'")
-							a(
-								href,
-								ng-click="openRenameProjectModal()"
-							) #{translate("rename")}
-						li
-							a(
-								href,
-								ng-click="openCloneProjectModal()"
-							) #{translate("make_copy")}
+        .btn-group(ng-hide="selectedProjects.length != 1", dropdown).dropdown
+          a.btn.btn-default.dropdown-toggle(
+            href,
+            data-toggle="dropdown",
+            dropdown-toggle
+          ) #{translate("more")}
+            span.caret
+          ul.dropdown-menu.dropdown-menu-right(role="menu")
+            li(ng-show="getFirstSelectedProject().accessLevel == 'owner'")
+              a(
+                href,
+                ng-click="openRenameProjectModal()"
+              ) #{translate("rename")}
+            li
+              a(
+                href,
+                ng-click="openCloneProjectModal()"
+              ) #{translate("make_copy")}
 
 
-			.btn-toolbar(ng-show="filter == 'archived'")
-				.btn-group(ng-hide="selectedProjects.length < 1")
-					a.btn.btn-default(
-						href,
-						data-original-title="Restore",
-						data-toggle="tooltip",
-						data-placement="bottom",
-						ng-click="restoreSelectedProjects()"
-					) #{translate("restore")}
+      .btn-toolbar(ng-show="filter == 'archived'")
+        .btn-group(ng-hide="selectedProjects.length < 1")
+          a.btn.btn-default(
+            href,
+            data-original-title="Restore",
+            data-toggle="tooltip",
+            data-placement="bottom",
+            ng-click="restoreSelectedProjects()"
+          ) #{translate("restore")}
 
 
-				.btn-group(ng-hide="selectedProjects.length < 1")
-					a.btn.btn-danger(
-						href,
-						data-original-title="Delete Forever",
-						data-toggle="tooltip",
-						data-placement="bottom",
-						ng-click="openDeleteProjectsModal()"
-					) #{translate("delete_forever")}
+        .btn-group(ng-hide="selectedProjects.length < 1")
+          a.btn.btn-danger(
+            href,
+            data-original-title="Delete Forever",
+            data-toggle="tooltip",
+            data-placement="bottom",
+            ng-click="openDeleteProjectsModal()"
+          ) #{translate("delete_forever")}
 
 
 .row.row-spaced
 .row.row-spaced
-	each warning in warnings
-		.col-xs-12
-			.alert.alert-warning(role="alert")= warning
+  each warning in warnings
+    .col-xs-12
+      .alert.alert-warning(role="alert")= warning
 
 
-	.col-xs-12
-		.card.card-thin.project-list-card
-			ul.list-unstyled.project-list.structured-list(
-				select-all-list,
-				ng-if="projects.length > 0",
-				max-height="projectListHeight - 25",
-				ng-cloak
-			)
-				li.container-fluid
-					.row
-						- 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"
+  .col-xs-12
+    .card.card-thin.project-list-card
+      .table-wrapper(max-height="projectListHeight - 25",)
+        table.table.table-hover.project-list(
+          select-all-list,
+          ng-if="projects.length > 0",
+          ng-cloak
+        )
+          thead
+            tr
+              th.selectProject
+                input.select-all(
+                  select-all,
+                  type="checkbox"
+                  aria-label=translate('select_all_projects')
+                )
+              th.projectName
+                span.header.clickable(ng-click="changePredicate('name')") #{translate("title")}
+                  i.tablesort.fa(ng-class="getSortIconClass('name')")
+              th
+                span.header.clickable(ng-click="changePredicate('accessLevel')") #{translate("owner")}
+                  i.tablesort.fa(ng-class="getSortIconClass('accessLevel')")
+              th
+                span.header.clickable(ng-click="changePredicate('lastUpdated')") #{translate("last_modified")}
+                  i.tablesort.fa(ng-class="getSortIconClass('lastUpdated')")
+              if settings.overleaf
+                th.text-right
+                  span.header #{translate("actions")}
+          tbody
+            tr.project_entry(
+              ng-repeat="project in visibleProjects | orderBy:predicate:reverse",
+              ng-controller="ProjectListItemController"
+            )
+              include ./item
+            tr(
+              ng-if="visibleProjects.length == 0",
+              ng-cloak
+            )
+              td(colspan=5).text-center
+                small #{translate("no_projects")}
 
 
-						div(class=titleClasses)
-							input.select-all(
-								select-all,
-								type="checkbox"
-								aria-label=translate('select_all_projects')
-							)
-							span.header.clickable(ng-click="changePredicate('name')") #{translate("title")}
-								i.tablesort.fa(ng-class="getSortIconClass('name')")
-						.col-xs-2
-							span.header.clickable(ng-click="changePredicate('accessLevel')") #{translate("owner")}
-								i.tablesort.fa(ng-class="getSortIconClass('accessLevel')")
-						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"
-				)
-					.row(
-						ng-if="!project.isV1Project"
-						select-row
-					)
-						include ./item
-					.row(
-						ng-if="project.isV1Project"
-					)
-						include ./v1-item
-				li(
-					ng-if="visibleProjects.length == 0",
-					ng-cloak
-				)
-					.row
-						.col-xs-12.text-centered
-							small #{translate("no_projects")}
+      div.welcome.text-centered(ng-if="projects.length == 0", ng-cloak)
+        h2 #{translate("welcome_to_sl")}
+        p #{translate("new_to_latex_look_at")}
+          a(href="/templates") #{translate("templates").toLowerCase()}
+          |   #{translate("or")}
+          a(href="/learn")  #{translate("latex_help_guide")}
+          | ,
+          br
+          |  #{translate("or_create_project_left")}
 
 
-			div.welcome.text-centered(ng-if="projects.length == 0", ng-cloak)
-				h2 #{translate("welcome_to_sl")}
-				p #{translate("new_to_latex_look_at")} 
-					a(href="/templates") #{translate("templates").toLowerCase()}
-					|   #{translate("or")}
-					a(href="/learn")  #{translate("latex_help_guide")}
-					| ,
-					br 
-					|  #{translate("or_create_project_left")}
 
 
-							

+ 0 - 25
services/web/app/views/project/list/v1-item.pug

@@ -1,25 +0,0 @@
-.col-xs-6.col-sm-4.col-md-6
-	.select-item
-		span.v1-badge(
-			aria-label=translate("v1_badge")
-			tooltip-template="'v1ProjectTooltipTemplate'"
-			tooltip-append-to-body="true"
-		)
-	span
-		if settings.overleaf && settings.overleaf.host
-			button.btn.btn-link.projectName(
-				ng-click="openV1ImportModal(project)"
-				stop-propagation="click"
-				ng-show="project.accessLevel == 'owner'"
-			) {{project.name}}
-			a.projectName(
-				href=settings.overleaf.host + "/{{project.id}}"
-				target="_blank"
-				ng-hide="project.accessLevel == 'owner'"
-			) {{project.name}}
-
-.col-xs-2
-	span.owner {{ownerName()}}
-
-.col-xs-4.col-sm-3.col-md-2
-	span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}}

+ 11 - 11
services/web/public/coffee/main/project-list/project-list.coffee

@@ -13,7 +13,7 @@ define [
 		$scope.predicate = "lastUpdated"
 		$scope.predicate = "lastUpdated"
 		$scope.nUntagged = 0
 		$scope.nUntagged = 0
 		$scope.reverse = true
 		$scope.reverse = true
-		$scope.searchText = 
+		$scope.searchText =
 			value : ""
 			value : ""
 
 
 		$timeout () ->
 		$timeout () ->
@@ -37,7 +37,7 @@ define [
 		angular.element($window).bind "resize", () ->
 		angular.element($window).bind "resize", () ->
 			recalculateProjectListHeight()
 			recalculateProjectListHeight()
 			$scope.$apply()
 			$scope.$apply()
-			
+
 		# Allow tags to be accessed on projects as well
 		# Allow tags to be accessed on projects as well
 		projectsById = {}
 		projectsById = {}
 		for project in $scope.projects
 		for project in $scope.projects
@@ -56,7 +56,7 @@ define [
 					tag.selected = true
 					tag.selected = true
 				else
 				else
 					tag.selected = false
 					tag.selected = false
-		
+
 		$scope.changePredicate = (newPredicate)->
 		$scope.changePredicate = (newPredicate)->
 			if $scope.predicate == newPredicate
 			if $scope.predicate == newPredicate
 				$scope.reverse = !$scope.reverse
 				$scope.reverse = !$scope.reverse
@@ -145,7 +145,7 @@ define [
 					# We don't want hidden selections
 					# We don't want hidden selections
 					project.selected = false
 					project.selected = false
 
 
-			localStorage("project_list", JSON.stringify({ 
+			localStorage("project_list", JSON.stringify({
 				filter: $scope.filter,
 				filter: $scope.filter,
 				selectedTagId: selectedTag?._id
 				selectedTagId: selectedTag?._id
 			}))
 			}))
@@ -461,7 +461,7 @@ define [
 				resolve:
 				resolve:
 					project: () -> project
 					project: () -> project
 			)
 			)
-			
+
 		if storedUIOpts?.filter?
 		if storedUIOpts?.filter?
 			if storedUIOpts.filter == "tag" and storedUIOpts.selectedTagId?
 			if storedUIOpts.filter == "tag" and storedUIOpts.selectedTagId?
 				markTagAsSelected(storedUIOpts.selectedTagId)
 				markTagAsSelected(storedUIOpts.selectedTagId)
@@ -485,11 +485,11 @@ define [
 		$scope.isLinkSharingProject = (project) ->
 		$scope.isLinkSharingProject = (project) ->
 			return project.source == 'token'
 			return project.source == 'token'
 
 
-		$scope.ownerName = () ->
-			if $scope.project.accessLevel == "owner"
+		$scope.userDisplayName = (user) ->
+			if user? and user._id == window.user_id
 				return "You"
 				return "You"
-			else if $scope.project.owner?
-				return [$scope.project.owner.first_name, $scope.project.owner.last_name].filter((n) -> n?).join(" ")
+			else if user?
+				return [user.first_name, user.last_name].filter((n) -> n?).join(" ")
 			else
 			else
 				return "None"
 				return "None"
 
 
@@ -535,11 +535,11 @@ define [
 					url: "/project/#{$scope.project.id}?forever=true"
 					url: "/project/#{$scope.project.id}?forever=true"
 					headers:
 					headers:
 						"X-CSRF-Token": window.csrfToken
 						"X-CSRF-Token": window.csrfToken
-				}).then () -> 
+				}).then () ->
 					$scope.project.isTableActionInflight = false
 					$scope.project.isTableActionInflight = false
 					$scope._removeProjectFromList $scope.project
 					$scope._removeProjectFromList $scope.project
 					for tag in $scope.tags
 					for tag in $scope.tags
 						$scope._removeProjectIdsFromTagArray(tag, [ $scope.project.id ])
 						$scope._removeProjectIdsFromTagArray(tag, [ $scope.project.id ])
 					$scope.updateVisibleProjects()
 					$scope.updateVisibleProjects()
-				.catch () -> 
+				.catch () ->
 					$scope.project.isTableActionInflight = false
 					$scope.project.isTableActionInflight = false

+ 39 - 7
services/web/public/stylesheets/app/project-list.less

@@ -62,7 +62,7 @@
 			.small {
 			.small {
 				color: @sidebar-color;
 				color: @sidebar-color;
 			}
 			}
-		} 
+		}
 
 
 		.project-list-sidebar when (@is-overleaf) {
 		.project-list-sidebar when (@is-overleaf) {
 			overflow-x: hidden;
 			overflow-x: hidden;
@@ -271,7 +271,7 @@ ul.structured-list {
 	li {
 	li {
 		border-bottom: 1px solid @structured-list-border-color;
 		border-bottom: 1px solid @structured-list-border-color;
 		padding: (@line-height-computed / 4) 0;
 		padding: (@line-height-computed / 4) 0;
-		
+
 		&:last-child {
 		&:last-child {
 			border-bottom: 0 none;
 			border-bottom: 0 none;
 		}
 		}
@@ -294,7 +294,7 @@ ul.structured-list {
 		.header when (@is-overleaf = false) {
 		.header when (@is-overleaf = false) {
 			text-transform: uppercase;
 			text-transform: uppercase;
 		}
 		}
-		
+
 		.select-item, .select-all {
 		.select-item, .select-all {
 			position: absolute;
 			position: absolute;
 			left: @line-height-computed;
 			left: @line-height-computed;
@@ -314,8 +314,38 @@ ul.structured-list {
 	padding: 0 (@line-height-computed / 4);
 	padding: 0 (@line-height-computed / 4);
 }
 }
 
 
-ul.project-list {
-	li {
+.table-wrapper {
+	overflow: scroll;
+}
+
+table.project-list {
+	margin: 0;
+	width: 100%;
+
+	th when (@is-overleaf = true) {
+		font-weight: 600;
+	}
+	th when (@is-overleaf = false) {
+		text-transform: uppercase;
+		font-weight: normal;
+	}
+
+	// thead > tr > th {
+	//  padding-top: @line-height-computed / 8;
+	// }
+
+	td {
+		@media (min-width: @screen-md-min) {
+			white-space: nowrap;
+		}
+		&.projectName {
+			white-space: normal;
+			width: 50%;
+		}
+		&.selectProject {
+			width: 1%;
+		}
+
 		.last-modified when (@is-overleaf = false) {
 		.last-modified when (@is-overleaf = false) {
 			font-size: .8rem;
 			font-size: .8rem;
 		}
 		}
@@ -325,12 +355,13 @@ ul.project-list {
 		.owner when (@is-overleaf = false) {
 		.owner when (@is-overleaf = false) {
 			margin-right: 0;
 			margin-right: 0;
 		}
 		}
-		.projectName {
+		a.projectName, button.projectName {
 			margin-right: @line-height-computed / 4;
 			margin-right: @line-height-computed / 4;
 			padding: 0;
 			padding: 0;
 			vertical-align: inherit;
 			vertical-align: inherit;
 			white-space: normal;
 			white-space: normal;
 			text-align: left;
 			text-align: left;
+			color: @structured-list-link-color;
 		}
 		}
 
 
 		.tag-label {
 		.tag-label {
@@ -369,6 +400,7 @@ ul.project-list {
 
 
 		.v1-badge {
 		.v1-badge {
 			margin-left: -4px;
 			margin-left: -4px;
+			margin-right: -4px;
 		}
 		}
 
 
 		.action-btn-row-header, .action-btn-row {
 		.action-btn-row-header, .action-btn-row {
@@ -564,7 +596,7 @@ ul.project-list {
 			&:last-child {
 			&:last-child {
 				margin-bottom: 0;
 				margin-bottom: 0;
 			}
 			}
-		}	
+		}
 			.announcement-header {
 			.announcement-header {
 				.page-header;
 				.page-header;
 				margin: 0;
 				margin: 0;

+ 1 - 1
services/web/public/stylesheets/components/tables.less

@@ -34,7 +34,7 @@ th {
   // Bottom align for column headings
   // Bottom align for column headings
   > thead > tr > th {
   > thead > tr > th {
     vertical-align: bottom;
     vertical-align: bottom;
-    border-bottom: 2px solid @table-border-color;
+    border-bottom: 1px solid @table-border-color;
   }
   }
   // Remove top border from thead by default
   // Remove top border from thead by default
   > caption + thead,
   > caption + thead,

+ 1 - 1
services/web/public/stylesheets/core/_common-variables.less

@@ -110,7 +110,7 @@
 //## Customizes the `.table` component with basic values, each used across all table variations.
 //## Customizes the `.table` component with basic values, each used across all table variations.
 
 
 //** Padding for `<th>`s and `<td>`s.
 //** Padding for `<th>`s and `<td>`s.
-@table-cell-padding:            8px;
+@table-cell-padding:            @line-height-computed / 4;
 //** Padding for cells in `.table-condensed`.
 //** Padding for cells in `.table-condensed`.
 @table-condensed-cell-padding:  5px;
 @table-condensed-cell-padding:  5px;
 
 

+ 42 - 0
services/web/test/acceptance/coffee/ProjectLastUpdatedTests.coffee

@@ -0,0 +1,42 @@
+expect = require("chai").expect
+async = require("async")
+User = require "./helpers/User"
+request = require "./helpers/request"
+settings = require "settings-sharelatex"
+Project = require("../../../app/js/models/Project").Project
+
+markAsUpdated = (project_id, user_id, timestamp, callback) ->
+	request.post {
+		url: "/project/#{project_id}/last_updated"
+		json: {
+			user_id,
+			timestamp
+		}
+		auth:
+			user: settings.apis.web.user
+			pass: settings.apis.web.pass
+			sendImmediately: true
+		jar: false
+	}, callback
+
+describe "ProjectLastUpdated", ->
+	before (done) ->
+		@timeout(90000)
+		@owner = new User()
+		@timestamp = Date.now()
+		@user_id = "abcdef1234567890abcdef12"
+		async.series [
+			(cb) => @owner.login cb
+			(cb) => @owner.createProject "private-project", (error, @project_id) => cb(error)
+		], done
+
+	describe "with user_id and timestamp", ->
+		it 'should update the project', (done) ->
+			markAsUpdated @project_id, @user_id, @timestamp, (error, response, body) =>
+				return done(error) if error?
+				expect(response.statusCode).to.equal 200
+				Project.findOne _id: @project_id, (error, project) =>
+					return done(error) if error?
+					expect(project.lastUpdated.getTime()).to.equal @timestamp
+					expect(project.lastUpdatedBy.toString()).to.equal @user_id
+					done()

+ 1 - 0
services/web/test/unit/coffee/History/HistoryControllerTests.coffee

@@ -23,6 +23,7 @@ describe "HistoryController", ->
 			"../Project/ProjectDetailsHandler": @ProjectDetailsHandler = {}
 			"../Project/ProjectDetailsHandler": @ProjectDetailsHandler = {}
 			"../Project/ProjectEntityUpdateHandler": @ProjectEntityUpdateHandler = {}
 			"../Project/ProjectEntityUpdateHandler": @ProjectEntityUpdateHandler = {}
 			"./RestoreManager": @RestoreManager = {}
 			"./RestoreManager": @RestoreManager = {}
+			"../Project/ProjectUpdateHandler": @ProjectUpdateHandler = {}
 		@settings.apis =
 		@settings.apis =
 			trackchanges:
 			trackchanges:
 				enabled: false
 				enabled: false

+ 0 - 5
services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee

@@ -190,11 +190,6 @@ describe 'ProjectEntityUpdateHandler', ->
 					.calledWith(project_id, doc_id, @docLines, @version, @ranges)
 					.calledWith(project_id, doc_id, @docLines, @version, @ranges)
 					.should.equal true
 					.should.equal true
 
 
-			it "should mark the project as updated", ->
-				@ProjectUpdater.markAsUpdated
-					.calledWith(project_id)
-					.should.equal true
-
 			it "should send the doc the to the TPDS", ->
 			it "should send the doc the to the TPDS", ->
 				@TpdsUpdateSender.addDoc
 				@TpdsUpdateSender.addDoc
 					.calledWith({
 					.calledWith({

+ 9 - 6
services/web/test/unit/coffee/Project/ProjectUpdateHandlerTests.coffee

@@ -16,12 +16,15 @@ describe 'ProjectUpdateHandler', ->
 	describe 'marking a project as recently updated', ->
 	describe 'marking a project as recently updated', ->
 		it 'should send an update to mongo', (done)->
 		it 'should send an update to mongo', (done)->
 			project_id = "project_id"
 			project_id = "project_id"
-			@handler.markAsUpdated project_id, (err)=>
-				args = @ProjectModel.update.args[0]
-				args[0]._id.should.equal project_id
-				date = args[1].lastUpdated+""
-				now = Date.now()+""
-				date.substring(0,5).should.equal now.substring(0,5)
+			user_id = "mock_user_id"
+			timestamp = Date.now()
+			@handler.markAsUpdated project_id, user_id, timestamp, (err)=>
+				@ProjectModel.update.calledWith({
+					_id: project_id,
+				}, {
+					lastUpdated: new Date(timestamp),
+					lastUpdatedBy: user_id
+				}).should.equal true
 				done()
 				done()
 
 
 	describe "markAsOpened", ->
 	describe "markAsOpened", ->