bunch of works on the authors page (which includes role shit)

This commit is contained in:
MiniDigger 2020-07-25 18:07:37 +02:00
parent 289dab9616
commit 2e5e49893d
20 changed files with 1618 additions and 1252 deletions

File diff suppressed because it is too large Load Diff

19
pom.xml
View File

@ -83,6 +83,12 @@
<artifactId>freemarker</artifactId>
<version>2.3.30</version>
</dependency>
<!-- java 8 objects in freemarker -->
<dependency>
<groupId>no.api.freemarker</groupId>
<artifactId>freemarker-java8</artifactId>
<version>2.0.0</version>
</dependency>
<!-- jdbi -->
<dependency>
@ -101,6 +107,14 @@
<groupId>org.jdbi</groupId>
<artifactId>jdbi3-spring4</artifactId>
</dependency>
<dependency>
<groupId>org.jdbi</groupId>
<artifactId>jdbi3-stringtemplate4</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<!-- webjars -->
<dependency>
@ -151,11 +165,6 @@
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>

View File

@ -0,0 +1,12 @@
package me.minidigger.hangar.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableCaching
public class CacheConfig {
public static final String AUTHORS_CACHE = "AUTHORS_CACHE";
}

View File

@ -36,6 +36,9 @@ public class HangarConfig {
@Value("${defaultChannelColor:7}")
private int defaultChannelColor;
@Value("${authorPageSize:25}")
private long authorPageSize;
public boolean isFakeUserEnabled() {
return fakeUserEnabled;
}
@ -77,4 +80,8 @@ public class HangarConfig {
throw new UnsupportedOperationException("this function is supported in debug mode only");
}
}
public long getAuthorPageSize() {
return authorPageSize;
}
}

View File

@ -3,6 +3,7 @@ package me.minidigger.hangar.config;
import org.jdbi.v3.core.Jdbi;
import org.jdbi.v3.core.spi.JdbiPlugin;
import org.jdbi.v3.postgres.PostgresPlugin;
import org.jdbi.v3.postgres.PostgresTypes;
import org.jdbi.v3.spring4.JdbiFactoryBean;
import org.jdbi.v3.sqlobject.SqlObjectPlugin;
import org.springframework.beans.factory.InjectionPoint;
@ -11,10 +12,12 @@ import org.springframework.beans.factory.config.DependencyDescriptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy;
import java.util.List;
import javax.sql.DataSource;
import me.minidigger.hangar.db.customtypes.RoleCategory;
import me.minidigger.hangar.db.dao.HangarDao;
@Configuration
@ -31,10 +34,12 @@ public class JDBIConfig {
}
@Bean
public JdbiFactoryBean jdbiFactoryBean(DataSource dataSource, List<JdbiPlugin> jdbiPlugins) {
JdbiFactoryBean jdbiFactoryBean = new JdbiFactoryBean(dataSource);
jdbiFactoryBean.setPlugins(jdbiPlugins);
return jdbiFactoryBean;
public Jdbi jdbi(DataSource dataSource, List<JdbiPlugin> jdbiPlugins) {
TransactionAwareDataSourceProxy dataSourceProxy = new TransactionAwareDataSourceProxy(dataSource);
Jdbi jdbi = Jdbi.create(dataSourceProxy);
jdbiPlugins.forEach(jdbi::installPlugin);
jdbi.configure(PostgresTypes.class, pt -> pt.registerCustomType(RoleCategory.class, "role_category"));
return jdbi;
}
@Bean

View File

@ -22,6 +22,7 @@ import java.io.IOException;
import java.util.concurrent.TimeUnit;
import me.minidigger.hangar.util.RouteHelper;
import no.api.freemarker.java8.Java8ObjectWrapper;
@EnableWebMvc
@Configuration
@ -55,6 +56,7 @@ public class MvcConfig implements WebMvcConfigurer {
freeMarkerConfigurer.getConfiguration().setOutputEncoding("UTF-8");
freeMarkerConfigurer.getConfiguration().setLogTemplateExceptions(false);
freeMarkerConfigurer.getConfiguration().setAPIBuiltinEnabled(true);
freeMarkerConfigurer.getConfiguration().setObjectWrapper(new Java8ObjectWrapper(freemarker.template.Configuration.getVersion()));
freeMarkerConfigurer.getConfiguration().setTemplateExceptionHandler((te, env, out) -> {
String message = te.getMessage();
if (message.contains("org.springframework.web.servlet.support.RequestContext.getMessage")) {

View File

@ -17,6 +17,7 @@ import me.minidigger.hangar.db.dao.HangarDao;
import me.minidigger.hangar.db.dao.UserDao;
import me.minidigger.hangar.db.model.UsersTable;
import me.minidigger.hangar.service.AuthenticationService;
import me.minidigger.hangar.service.UserService;
@Controller
public class UsersController extends HangarController {
@ -25,18 +26,25 @@ public class UsersController extends HangarController {
private final HangarConfig hangarConfig;
private final AuthenticationService authenticationService;
private final ApplicationController applicationController;
private final UserService userService;
@Autowired
public UsersController(HangarConfig hangarConfig, HangarDao<UserDao> userDao, AuthenticationService authenticationService, ApplicationController applicationController) {
public UsersController(HangarConfig hangarConfig, HangarDao<UserDao> userDao, AuthenticationService authenticationService, ApplicationController applicationController, UserService userService) {
this.hangarConfig = hangarConfig;
this.userDao = userDao;
this.authenticationService = authenticationService;
this.applicationController = applicationController;
this.userService = userService;
}
@RequestMapping("/authors")
public ModelAndView showAuthors(@RequestParam(required = false) Object sort, @RequestParam(required = false) Object page) {
return fillModel(new ModelAndView("users/authors")); // TODO implement showAuthors request controller
public ModelAndView showAuthors(@RequestParam(required = false, defaultValue = "projects") String sort, @RequestParam(required = false, defaultValue = "1") int page) {
ModelAndView mav = new ModelAndView("users/authors");
mav.addObject("authors", userService.getAuthors(page, sort));
mav.addObject("ordering", sort);
mav.addObject("page", page);
mav.addObject("pageSize", hangarConfig.getAuthorPageSize());
return fillModel(mav);
}
@RequestMapping("/login")

View File

@ -1,15 +1,52 @@
package me.minidigger.hangar.db.customtypes;
public enum RoleCategory {
//TODO implement RoleCategory type
import org.postgresql.util.PGobject;
GLOBAL("global"),
PROJECT("project"),
ORGANIZATION("organization");
import java.util.Objects;
public class RoleCategory extends PGobject {
public static final RoleCategory GLOBAL = new RoleCategory("global");
public static final RoleCategory PROJECT = new RoleCategory("project");
public static final RoleCategory ORGANIZATION = new RoleCategory("organization");
private String value;
RoleCategory(String value) {
this();
this.value = value;
}
public RoleCategory() {
setType("role_category");
}
@Override
public void setValue(String value) {
this.value = value;
}
@Override
public String getValue() {
return value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
RoleCategory that = (RoleCategory) o;
return Objects.equals(value, that.value);
}
@Override
public int hashCode() {
int result = super.hashCode();
result = 31 * result + (value != null ? value.hashCode() : 0);
return result;
}
}

View File

@ -0,0 +1,20 @@
package me.minidigger.hangar.db.dao;
import org.jdbi.v3.sqlobject.config.RegisterBeanMapper;
import org.jdbi.v3.sqlobject.customizer.BindBean;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import org.springframework.stereotype.Repository;
import me.minidigger.hangar.db.model.RolesTable;
@Repository
@RegisterBeanMapper(RolesTable.class)
public interface RoleDao {
@SqlUpdate("INSERT INTO roles VALUES (:id, :name, :category, :title, :color, :isAssignable, :rank, CAST(:permission AS bit(64)))")
void insert(@BindBean RolesTable role);
@SqlQuery("SELECT id, name, category, title, color, is_assignable, rank, permission AS long FROM roles WHERE id = :id")
RolesTable getById(long id);
}

View File

@ -2,13 +2,18 @@ package me.minidigger.hangar.db.dao;
import org.jdbi.v3.sqlobject.config.RegisterBeanMapper;
import org.jdbi.v3.sqlobject.customizer.BindBean;
import org.jdbi.v3.sqlobject.customizer.Define;
import org.jdbi.v3.sqlobject.customizer.Timestamped;
import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import org.jdbi.v3.stringtemplate4.UseStringTemplateEngine;
import org.springframework.stereotype.Repository;
import java.util.List;
import me.minidigger.hangar.db.model.UsersTable;
import me.minidigger.hangar.model.viewhelpers.Author;
@Repository
@RegisterBeanMapper(UsersTable.class)
@ -24,4 +29,33 @@ public interface UserDao {
@SqlQuery("select * from users where LOWER(name) = LOWER(:name)")
UsersTable getByName(String name);
@SqlQuery("SELECT sq.name," +
" sq.join_date," +
" sq.created_at," +
" sq.role," +
" sq.donator_role," +
" sq.count" +
" FROM (SELECT u.name," +
" u.join_date," +
" u.created_at," +
" r.name AS role," +
" r.permission," +
" (SELECT COUNT(*)" +
" FROM project_members_all pma" +
" WHERE pma.user_id = u.id) AS count," +
" CASE WHEN dr.rank IS NULL THEN NULL ELSE dr.name END AS donator_role," +
" row_number() OVER (PARTITION BY u.id ORDER BY r.permission::BIGINT DESC, dr.rank ASC NULLS LAST) AS row" +
" FROM projects p" +
" JOIN users u ON p.owner_id = u.id" +
" LEFT JOIN user_global_roles gr ON gr.user_id = u.id" +
" LEFT JOIN roles r ON gr.role_id = r.id" +
" LEFT JOIN user_global_roles dgr ON dgr.user_id = u.id" +
" LEFT JOIN roles dr ON dgr.role_id = dr.id) sq" +
" WHERE sq.row = 1" +
" <sort>" +
" OFFSET :offset LIMIT :pageSize")
@UseStringTemplateEngine
@RegisterBeanMapper(Author.class)
List<Author> getAuthors(long offset, long pageSize, @Define String sort);
}

View File

@ -2,6 +2,7 @@ package me.minidigger.hangar.db.model;
import me.minidigger.hangar.db.customtypes.RoleCategory;
import me.minidigger.hangar.model.Role;
public class RolesTable {
@ -11,9 +12,31 @@ public class RolesTable {
private String title;
private String color;
private boolean isAssignable;
private long rank;
private Long rank;
private long permission;
public static RolesTable fromRole(Role role) {
return new RolesTable(role.getRoleId(), role.getValue(), role.getCategory(), role.getTitle(), role.getColor().getHex(), role.isAssignable(), null, role.getPermissions().getValue());
}
public RolesTable() {
//
}
public RolesTable(long id, String name, RoleCategory category, String title, String color, boolean isAssignable, Long rank, long permission) {
this.id = id;
this.name = name;
this.category = category;
this.title = title;
this.color = color;
this.isAssignable = isAssignable;
this.rank = rank;
this.permission = permission;
}
public Role getRole() {
return Role.fromTitle(title);
}
public long getId() {
return id;
@ -69,11 +92,11 @@ public class RolesTable {
}
public long getRank() {
public Long getRank() {
return rank;
}
public void setRank(long rank) {
public void setRank(Long rank) {
this.rank = rank;
}

View File

@ -87,6 +87,10 @@ public class Permission implements Comparable<Permission> {
return Arrays.stream(NamedPermission.values()).filter(perm -> has(perm.getPermission())).collect(Collectors.toUnmodifiableList());
}
public long getValue() {
return value;
}
@Override
public int compareTo(Permission o) {
return (int) (value - o.value);

View File

@ -22,9 +22,9 @@ public enum Role {
WEB_DEV("Web_Dev", 9, GLOBAL, ViewLogs.add(ViewHealth), "Web Developer", BLUE),
DOCUMENTER("Documenter", 10, GLOBAL, None, "Documenter", AQUA),
SUPPORT("Support", 10, GLOBAL, None, "Support", AQUA),
CONTRIBUTOR("Contributor", 10, GLOBAL, None, "Contributor", GREEN),
ADVISOR("Advisor", 10, GLOBAL, None, "Advisor", AQUA),
SUPPORT("Support", 11, GLOBAL, None, "Support", AQUA),
CONTRIBUTOR("Contributor", 12, GLOBAL, None, "Contributor", GREEN),
ADVISOR("Advisor", 13, GLOBAL, None, "Advisor", AQUA),
STONE_DONOR("Stone_Donor", 14, GLOBAL, None, "Stone Donor", GRAY),
QUARTZ_DONOR("Quartz_Donor",15, GLOBAL, None, "Quartz Donor", QUARTZ),
@ -93,4 +93,13 @@ public enum Role {
public boolean isAssignable() {
return isAssignable;
}
public static Role fromTitle(String title) {
for (Role r : values()) {
if (r.title.equals(title)) {
return r;
}
}
return null;
}
}

View File

@ -0,0 +1,9 @@
package me.minidigger.hangar.model;
public class UserOrdering {
public static final String Projects = "projects";
public static final String UserName = "username";
public static final String JoinDate = "joined";
public static final String Role = "roles";
}

View File

@ -0,0 +1,74 @@
package me.minidigger.hangar.model.viewhelpers;
import java.time.OffsetDateTime;
public class Author {
private String name;
private OffsetDateTime joinDate;
private OffsetDateTime createdAt;
private String role;
private String donatorRole;
private long projectCount;
public Author() {
//
}
public Author(String name, OffsetDateTime joinDate, OffsetDateTime createdAt, String role, String donatorRole, long projectCount) {
this.name = name;
this.joinDate = joinDate;
this.createdAt = createdAt;
this.role = role;
this.donatorRole = donatorRole;
this.projectCount = projectCount;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public OffsetDateTime getJoinDate() {
return joinDate;
}
public void setJoinDate(OffsetDateTime joinDate) {
this.joinDate = joinDate;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public String getDonatorRole() {
return donatorRole;
}
public void setDonatorRole(String donatorRole) {
this.donatorRole = donatorRole;
}
public long getProjectCount() {
return projectCount;
}
public void setProjectCount(long projectCount) {
this.projectCount = projectCount;
}
}

View File

@ -0,0 +1,43 @@
package me.minidigger.hangar.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import me.minidigger.hangar.controller.generated.ProjectsApiController;
import me.minidigger.hangar.db.dao.HangarDao;
import me.minidigger.hangar.db.dao.RoleDao;
import me.minidigger.hangar.db.model.RolesTable;
import me.minidigger.hangar.model.Role;
@Service
public class RoleService {
private static final Logger log = LoggerFactory.getLogger(RoleService.class);
private final HangarDao<RoleDao> roleDao;
@Autowired
public RoleService(HangarDao<RoleDao> roleDao) {
this.roleDao = roleDao;
init();
}
public void init() {
RolesTable admin = roleDao.get().getById(1);
if(admin != null && admin.getRole().equals(Role.HANGAR_ADMIN)) {
log.info("Skipping role init");
return;
}
log.info("Initializing roles (first start only)");
for (Role role : Role.values()) {
roleDao.get().insert(RolesTable.fromRole(role));
}
}
// public void addRole() {
// boolean exists =
// }
}

View File

@ -1,17 +1,36 @@
package me.minidigger.hangar.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import java.util.List;
import me.minidigger.hangar.config.CacheConfig;
import me.minidigger.hangar.config.HangarConfig;
import me.minidigger.hangar.db.dao.HangarDao;
import me.minidigger.hangar.db.dao.UserDao;
import me.minidigger.hangar.db.model.UsersTable;
import me.minidigger.hangar.model.UserOrdering;
import me.minidigger.hangar.model.generated.ModelData;
import me.minidigger.hangar.model.viewhelpers.Author;
import me.minidigger.hangar.security.HangarAuthentication;
@Service
public class UserService {
private final HangarDao<UserDao> userDao;
private final HangarConfig config;
@Autowired
public UserService(HangarDao<UserDao> userDao, HangarConfig config) {
this.userDao = userDao;
this.config = config;
}
public UsersTable getCurrentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && !(authentication instanceof AnonymousAuthenticationToken)) {
@ -31,4 +50,39 @@ public class UserService {
return modelData;
}
@Cacheable(CacheConfig.AUTHORS_CACHE)
public List<Author> getAuthors(int page, String sort) {
boolean reverse = true;
if (sort.startsWith("-")) {
sort = sort.substring(1);
reverse = false;
}
long pageSize = config.getAuthorPageSize();
long offset = (page - 1) * pageSize;
return userDao.get().getAuthors(offset, pageSize, userOrder(reverse, sort));
}
private String userOrder(boolean reverse, String sortStr) {
String sort = reverse ? " ASC" : " DESC";
String sortUserName = "sq.name" + sort;
String thenSortUserName = "," + sortUserName;
switch (sortStr) {
case UserOrdering.JoinDate:
return "ORDER BY sq.join_date" + sort;
case UserOrdering.UserName:
return "ORDER BY " + sortUserName;
case UserOrdering.Projects:
return "ORDER BY sq.count" + sort + thenSortUserName;
case UserOrdering.Role:
return "ORDER BY sq.permission::BIGINT" + sort + " NULLS LAST" + ", sq.role" + sort + thenSortUserName;
default:
return " ";
}
}
}

View File

@ -11,7 +11,9 @@ import me.minidigger.hangar.db.model.ProjectChannelsTable;
import me.minidigger.hangar.db.model.ProjectsTable;
import me.minidigger.hangar.db.model.UsersTable;
import me.minidigger.hangar.model.Category;
import me.minidigger.hangar.model.Role;
import me.minidigger.hangar.model.Visibility;
import me.minidigger.hangar.service.RoleService;
import me.minidigger.hangar.util.StringUtils;
@Component
@ -20,12 +22,14 @@ public class ProjectFactory {
private final HangarConfig hangarConfig;
private final HangarDao<ProjectChannelDao> projectChannelDao;
private final HangarDao<ProjectDao> projectDao;
private final RoleService roleService;
@Autowired
public ProjectFactory(HangarConfig hangarConfig, HangarDao<ProjectChannelDao> projectChannelDao, HangarDao<ProjectDao> projectDao) {
public ProjectFactory(HangarConfig hangarConfig, HangarDao<ProjectChannelDao> projectChannelDao, HangarDao<ProjectDao> projectDao, RoleService roleService) {
this.hangarConfig = hangarConfig;
this.projectChannelDao = projectChannelDao;
this.projectDao = projectDao;
this.roleService = roleService;
}
public String getUploadError(UsersTable user) {
@ -52,6 +56,7 @@ public class ProjectFactory {
projectChannelDao.get().insert(channelsTable);
// TODO role stuff
// roleService.addRole(projectsTable, ownerUser.getId(), Role.PROJECT_OWNER, true);
return projectsTable;
}

File diff suppressed because it is too large Load Diff

View File

@ -17,13 +17,22 @@
@(authors: Seq[(String, Option[OffsetDateTime], OffsetDateTime, Option[Role], Option[Role], Long)], ordering: String, page: Int)(implicit messages: Messages, request: OreRequest[_], config: OreConfig, flash: Flash, assetsFinder: AssetsFinder)
@pageSize = @{ config.ore.users.authorPageSize }
@direction = @{ if (ordering.startsWith("-")) "chevron-down" else "chevron-up" }
-->
<#function direction>
<#if ordering?startsWith("-")>
<#return "chevron-down">
<#else>
<#return "chevron-up">
</#if>
</#function>
<#function isActiveSort td>
var sort = ordering
if (sort.startsWith("-")) sort = sort.substring(1)
<#return sort.equalsIgnoreCase(td)>
<#assign sort=ordering>
<#if sort?startsWith("-")>
<#assign sort=sort?substring(1)>
</#if>
<#return sort?upperCase == td?upperCase>
</#function>
<#assign scriptsVar>
@ -32,6 +41,8 @@
</#assign>
<@base.base title="Authors - Hangar" additionalScripts=scriptsVar>
<#assign UserOrdering = @helper["me.minidigger.hangar.model.UserOrdering"]>
<#-- @ftlvariable name="UserOrdering" type="me.minidigger.hangar.model.UserOrdering" -->
<div class="panel panel-default">
<table class="table table-users">
<thead>
@ -39,56 +50,55 @@
<td></td>
<td <#if isActiveSort(UserOrdering.UserName)>class="user-sort"</#if> data-list="authors" >
Username
<#if isActiveSort(UserOrdering.UserName)><i class="o fas fa-@direction"></i></#if>
<#if isActiveSort(UserOrdering.UserName)><i class="o fas fa-${direction()}"></i></#if>
</td>
<td <#if isActiveSort(UserOrdering.Role)>class="user-sort"</#if> data-list="authors" >
Roles
<#if isActiveSort(UserOrdering.Role)><i class="o fas fa-@direction"></i></#if>
<#if isActiveSort(UserOrdering.Role)><i class="o fas fa-${direction()}"></i></#if>
</td>
<td <#if isActiveSort(UserOrdering.JoinDate)>class="user-sort"</#if> data-list="authors" >
Joined
<#if isActiveSort(UserOrdering.JoinDate)><i class="o fas fa-@direction"></i></#if>
<#if isActiveSort(UserOrdering.JoinDate)><i class="o fas fa-${direction()}"></i></#if>
</td>
<td <#if isActiveSort(UserOrdering.Projects)>class="user-sort"</#if> data-list="authors" >
Projects
<#if isActiveSort(UserOrdering.Projects)><i class="o fas fa-${direction}"></i></#if>
<#if isActiveSort(UserOrdering.Projects)><i class="o fas fa-${direction()}"></i></#if>
</td>
</tr>
</thead>
<tbody>
@authors.map { case (name, joinDate, createdAt, optRole, optDonorRole, projectCount) =>
<#list authors as author>
<tr>
<#import "*/utils/userAvatar.ftlh" as userAvatar>
<td><@userAvatar.userAvatar userName=name avatarUrl=User.avatarUrl(name) clazz="user-avatar-xs"></@userAvatar.userAvatar></td>
<td><@userAvatar.userAvatar userName=author.name avatarUrl=User.avatarUrl(author.name) clazz="user-avatar-xs"></@userAvatar.userAvatar></td>
<td>
<a href="${routes.getRouteUrl("users.showProjects", name)}">${name}</a>
<a href="${routes.getRouteUrl("users.showProjects", author.name)}">${author.name}</a>
</td>
<td>
@optDonorRole.map { role =>
<#if author.donatorRole??>
<span class="channel channel-sm" style="background-color: ${role.color.hex}">
${role.title}
${author.donatorRole}
</span>
}
@optRole.map { role =>
</#if>
<#if author.role??>
<span class="user-role channel" style="background-color: ${role.color.hex}">
${role.title}
${author.role}
</span>
}
</#if>
</td>
<td>@prettifyDate(joinDate!createdAt)</td>
<td>${projectCount}</td>
<td>${(author.joinDate!author.createdAt).format("yyyy-MM-dd")}</td>
<td>${author.projectCount}</td>
</tr>
}
</#list>
<#if page gt 1 || authors.size gte pageSize>
<#if page gt 1 || authors?size gte pageSize>
<tr class="authors-footer">
<td></td>
<td></td>
<td></td>
<td></td>
<td>
<#if authors.size gte pageSize>
<#if authors?size gte pageSize>
<a href="${routes.getRouteUrl("users.showAuthors", ordering, page + 1)}" class="btn btn-default">
<i class="fas fa-arrow-right"></i>
</a>