Task Management Tool (Trello Clone) in Java

Project Overview

A comprehensive Trello-like task management application built with Java Spring Boot, featuring boards, lists, cards, user management, and real-time updates.

Technology Stack

  • Backend: Spring Boot 3.x, Spring Security, JPA/Hibernate
  • Frontend: Thymeleaf, Bootstrap, JavaScript
  • Database: PostgreSQL/MySQL
  • Real-time: WebSocket (STOMP)
  • Authentication: Spring Security with JWT
  • File Storage: Local file system / AWS S3
  • Testing: JUnit, Mockito, TestContainers

Project Structure

task-management-tool/
├── src/main/java/com/taskmanager/
│   ├── config/
│   ├── controller/
│   ├── service/
│   ├── repository/
│   ├── entity/
│   ├── dto/
│   ├── security/
│   └── exception/
├── src/main/resources/
│   ├── templates/
│   ├── static/
│   └── application.properties
└── pom.xml

Core Entities

@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String username;
@Column(nullable = false)
private String password;
private String avatarUrl;
@CreationTimestamp
private LocalDateTime createdAt;
@OneToMany(mappedBy = "owner")
private Set<Board> ownedBoards = new HashSet<>();
@ManyToMany(mappedBy = "members")
private Set<Board> memberBoards = new HashSet<>();
// Constructors, getters, setters
}
@Entity
@Table(name = "boards")
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
private String description;
private String color;
@CreationTimestamp
private LocalDateTime createdAt;
@ManyToOne
@JoinColumn(name = "owner_id")
private User owner;
@ManyToMany
@JoinTable(
name = "board_members",
joinColumns = @JoinColumn(name = "board_id"),
inverseJoinColumns = @JoinColumn(name = "user_id")
)
private Set<User> members = new HashSet<>();
@OneToMany(mappedBy = "board", cascade = CascadeType.ALL)
@OrderBy("position ASC")
private List<BoardList> lists = new ArrayList<>();
@OneToMany(mappedBy = "board", cascade = CascadeType.ALL)
private Set<BoardActivity> activities = new HashSet<>();
// Constructors, getters, setters
}
@Entity
@Table(name = "board_lists")
public class BoardList {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
private Integer position;
@CreationTimestamp
private LocalDateTime createdAt;
@ManyToOne
@JoinColumn(name = "board_id")
private Board board;
@OneToMany(mappedBy = "list", cascade = CascadeType.ALL)
@OrderBy("position ASC")
private List<Card> cards = new ArrayList<>();
// Constructors, getters, setters
}
@Entity
@Table(name = "cards")
public class Card {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
private String description;
private Integer position;
private LocalDateTime dueDate;
private String coverImage;
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
@ManyToOne
@JoinColumn(name = "list_id")
private BoardList list;
@ManyToMany
@JoinTable(
name = "card_assignees",
joinColumns = @JoinColumn(name = "card_id"),
inverseJoinColumns = @JoinColumn(name = "user_id")
)
private Set<User> assignees = new HashSet<>();
@OneToMany(mappedBy = "card", cascade = CascadeType.ALL)
private List<Comment> comments = new ArrayList<>();
@OneToMany(mappedBy = "card", cascade = CascadeType.ALL)
private List<Attachment> attachments = new ArrayList<>();
@OneToMany(mappedBy = "card", cascade = CascadeType.ALL)
private List<Checklist> checklists = new ArrayList<>();
// Constructors, getters, setters
}
@Entity
@Table(name = "comments")
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, columnDefinition = "TEXT")
private String content;
@CreationTimestamp
private LocalDateTime createdAt;
@ManyToOne
@JoinColumn(name = "user_id")
private User author;
@ManyToOne
@JoinColumn(name = "card_id")
private Card card;
// Constructors, getters, setters
}
@Entity
@Table(name = "attachments")
public class Attachment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String filename;
private String fileUrl;
private String fileType;
private Long fileSize;
@CreationTimestamp
private LocalDateTime createdAt;
@ManyToOne
@JoinColumn(name = "card_id")
private Card card;
@ManyToOne
@JoinColumn(name = "user_id")
private User uploadedBy;
// Constructors, getters, setters
}
@Entity
@Table(name = "checklists")
public class Checklist {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@CreationTimestamp
private LocalDateTime createdAt;
@ManyToOne
@JoinColumn(name = "card_id")
private Card card;
@OneToMany(mappedBy = "checklist", cascade = CascadeType.ALL)
private List<ChecklistItem> items = new ArrayList<>();
// Constructors, getters, setters
}
@Entity
@Table(name = "checklist_items")
public class ChecklistItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
private Boolean completed = false;
private Integer position;
@ManyToOne
@JoinColumn(name = "checklist_id")
private Checklist checklist;
// Constructors, getters, setters
}
@Entity
@Table(name = "board_activities")
public class BoardActivity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(columnDefinition = "TEXT")
private String description;
@CreationTimestamp
private LocalDateTime createdAt;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
@ManyToOne
@JoinColumn(name = "board_id")
private Board board;
private ActivityType type;
public enum ActivityType {
CARD_CREATED, CARD_MOVED, CARD_UPDATED, 
COMMENT_ADDED, MEMBER_ADDED, BOARD_UPDATED
}
// Constructors, getters, setters
}

Repository Layer

@Repository
public interface BoardRepository extends JpaRepository<Board, Long> {
List<Board> findByOwnerIdOrMembersId(Long ownerId, Long memberId);
List<Board> findByMembersId(Long memberId);
Optional<Board> findByIdAndMembersId(Long boardId, Long memberId);
}
@Repository
public interface BoardListRepository extends JpaRepository<BoardList, Long> {
List<BoardList> findByBoardIdOrderByPosition(Long boardId);
Optional<BoardList> findByIdAndBoardId(Long listId, Long boardId);
}
@Repository
public interface CardRepository extends JpaRepository<Card, Long> {
List<Card> findByListIdOrderByPosition(Long listId);
Optional<Card> findByIdAndListBoardId(Long cardId, Long boardId);
}
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Optional<User> findByUsername(String username);
Boolean existsByEmail(String email);
Boolean existsByUsername(String username);
}

Service Layer

@Service
@Transactional
public class BoardService {
@Autowired
private BoardRepository boardRepository;
@Autowired
private UserService userService;
@Autowired
private BoardActivityService activityService;
public Board createBoard(Board board, User owner) {
board.setOwner(owner);
board.getMembers().add(owner);
Board savedBoard = boardRepository.save(board);
activityService.recordActivity(
savedBoard, 
owner, 
String.format("%s created this board", owner.getUsername()),
BoardActivity.ActivityType.BOARD_UPDATED
);
return savedBoard;
}
public Board updateBoard(Long boardId, Board boardUpdate, User user) {
Board board = getBoardForUser(boardId, user);
if (boardUpdate.getTitle() != null) {
board.setTitle(boardUpdate.getTitle());
}
if (boardUpdate.getDescription() != null) {
board.setDescription(boardUpdate.getDescription());
}
if (boardUpdate.getColor() != null) {
board.setColor(boardUpdate.getColor());
}
activityService.recordActivity(
board, 
user, 
String.format("%s updated board details", user.getUsername()),
BoardActivity.ActivityType.BOARD_UPDATED
);
return boardRepository.save(board);
}
public void deleteBoard(Long boardId, User user) {
Board board = getBoardForUser(boardId, user);
if (!board.getOwner().getId().equals(user.getId())) {
throw new AccessDeniedException("Only board owner can delete the board");
}
boardRepository.delete(board);
}
public Board addMemberToBoard(Long boardId, String memberEmail, User currentUser) {
Board board = getBoardForUser(boardId, currentUser);
User member = userService.findByEmail(memberEmail)
.orElseThrow(() -> new RuntimeException("User not found"));
board.getMembers().add(member);
activityService.recordActivity(
board, 
currentUser, 
String.format("%s added %s to the board", currentUser.getUsername(), member.getUsername()),
BoardActivity.ActivityType.MEMBER_ADDED
);
return boardRepository.save(board);
}
public Board getBoardForUser(Long boardId, User user) {
return boardRepository.findByIdAndMembersId(boardId, user.getId())
.orElseThrow(() -> new RuntimeException("Board not found or access denied"));
}
public List<Board> getUserBoards(User user) {
return boardRepository.findByOwnerIdOrMembersId(user.getId(), user.getId());
}
}
@Service
@Transactional
public class CardService {
@Autowired
private CardRepository cardRepository;
@Autowired
private BoardListRepository listRepository;
@Autowired
private UserService userService;
@Autowired
private BoardActivityService activityService;
public Card createCard(Card card, Long listId, User user) {
BoardList list = listRepository.findById(listId)
.orElseThrow(() -> new RuntimeException("List not found"));
// Verify user has access to the board
Board board = list.getBoard();
if (!board.getMembers().contains(user)) {
throw new AccessDeniedException("No access to this board");
}
card.setList(list);
card.setPosition(getNextCardPosition(listId));
Card savedCard = cardRepository.save(card);
activityService.recordActivity(
board, 
user, 
String.format("%s created card '%s'", user.getUsername(), card.getTitle()),
BoardActivity.ActivityType.CARD_CREATED
);
return savedCard;
}
public Card updateCard(Long cardId, Card cardUpdate, User user) {
Card card = cardRepository.findById(cardId)
.orElseThrow(() -> new RuntimeException("Card not found"));
Board board = card.getList().getBoard();
if (!board.getMembers().contains(user)) {
throw new AccessDeniedException("No access to this board");
}
if (cardUpdate.getTitle() != null) {
card.setTitle(cardUpdate.getTitle());
}
if (cardUpdate.getDescription() != null) {
card.setDescription(cardUpdate.getDescription());
}
if (cardUpdate.getDueDate() != null) {
card.setDueDate(cardUpdate.getDueDate());
}
Card updatedCard = cardRepository.save(card);
activityService.recordActivity(
board, 
user, 
String.format("%s updated card '%s'", user.getUsername(), card.getTitle()),
BoardActivity.ActivityType.CARD_UPDATED
);
return updatedCard;
}
public Card moveCard(Long cardId, Long targetListId, Integer newPosition, User user) {
Card card = cardRepository.findById(cardId)
.orElseThrow(() -> new RuntimeException("Card not found"));
BoardList targetList = listRepository.findById(targetListId)
.orElseThrow(() -> new RuntimeException("Target list not found"));
Board sourceBoard = card.getList().getBoard();
Board targetBoard = targetList.getBoard();
if (!sourceBoard.getId().equals(targetBoard.getId())) {
throw new RuntimeException("Cannot move card between different boards");
}
if (!sourceBoard.getMembers().contains(user)) {
throw new AccessDeniedException("No access to this board");
}
// Update card positions in source list
updateCardPositions(card.getList().getId(), card.getPosition(), Integer.MAX_VALUE, -1);
// Update card positions in target list
updateCardPositions(targetListId, newPosition, Integer.MAX_VALUE, 1);
card.setList(targetList);
card.setPosition(newPosition);
Card movedCard = cardRepository.save(card);
activityService.recordActivity(
sourceBoard, 
user, 
String.format("%s moved card '%s'", user.getUsername(), card.getTitle()),
BoardActivity.ActivityType.CARD_MOVED
);
return movedCard;
}
public Card assignUserToCard(Long cardId, Long userId, User currentUser) {
Card card = cardRepository.findById(cardId)
.orElseThrow(() -> new RuntimeException("Card not found"));
User assignee = userService.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
Board board = card.getList().getBoard();
if (!board.getMembers().contains(currentUser) || !board.getMembers().contains(assignee)) {
throw new AccessDeniedException("No access to this board");
}
card.getAssignees().add(assignee);
activityService.recordActivity(
board, 
currentUser, 
String.format("%s assigned %s to card '%s'", 
currentUser.getUsername(), assignee.getUsername(), card.getTitle()),
BoardActivity.ActivityType.CARD_UPDATED
);
return cardRepository.save(card);
}
private Integer getNextCardPosition(Long listId) {
List<Card> cards = cardRepository.findByListIdOrderByPosition(listId);
return cards.isEmpty() ? 0 : cards.get(cards.size() - 1).getPosition() + 1;
}
private void updateCardPositions(Long listId, Integer fromPosition, Integer toPosition, Integer change) {
List<Card> cardsToUpdate = cardRepository.findByListIdOrderByPosition(listId)
.stream()
.filter(card -> {
int pos = card.getPosition();
return change > 0 ? pos >= fromPosition && pos < toPosition 
: pos > toPosition && pos <= fromPosition;
})
.collect(Collectors.toList());
for (Card card : cardsToUpdate) {
card.setPosition(card.getPosition() + change);
cardRepository.save(card);
}
}
}
@Service
@Transactional
public class CommentService {
@Autowired
private CommentRepository commentRepository;
@Autowired
private CardRepository cardRepository;
@Autowired
private BoardActivityService activityService;
public Comment addCommentToCard(Long cardId, String content, User author) {
Card card = cardRepository.findById(cardId)
.orElseThrow(() -> new RuntimeException("Card not found"));
Board board = card.getList().getBoard();
if (!board.getMembers().contains(author)) {
throw new AccessDeniedException("No access to this board");
}
Comment comment = new Comment();
comment.setContent(content);
comment.setAuthor(author);
comment.setCard(card);
Comment savedComment = commentRepository.save(comment);
activityService.recordActivity(
board, 
author, 
String.format("%s commented on card '%s'", author.getUsername(), card.getTitle()),
BoardActivity.ActivityType.COMMENT_ADDED
);
return savedComment;
}
public void deleteComment(Long commentId, User user) {
Comment comment = commentRepository.findById(commentId)
.orElseThrow(() -> new RuntimeException("Comment not found"));
if (!comment.getAuthor().getId().equals(user.getId())) {
throw new AccessDeniedException("Can only delete your own comments");
}
commentRepository.delete(comment);
}
}

Controller Layer

@RestController
@RequestMapping("/api/boards")
public class BoardController {
@Autowired
private BoardService boardService;
@GetMapping
public ResponseEntity<List<Board>> getUserBoards(Authentication authentication) {
User user = (User) authentication.getPrincipal();
List<Board> boards = boardService.getUserBoards(user);
return ResponseEntity.ok(boards);
}
@PostMapping
public ResponseEntity<Board> createBoard(@RequestBody Board board, Authentication authentication) {
User user = (User) authentication.getPrincipal();
Board createdBoard = boardService.createBoard(board, user);
return ResponseEntity.status(HttpStatus.CREATED).body(createdBoard);
}
@PutMapping("/{boardId}")
public ResponseEntity<Board> updateBoard(@PathVariable Long boardId, 
@RequestBody Board boardUpdate,
Authentication authentication) {
User user = (User) authentication.getPrincipal();
Board updatedBoard = boardService.updateBoard(boardId, boardUpdate, user);
return ResponseEntity.ok(updatedBoard);
}
@DeleteMapping("/{boardId}")
public ResponseEntity<Void> deleteBoard(@PathVariable Long boardId, 
Authentication authentication) {
User user = (User) authentication.getPrincipal();
boardService.deleteBoard(boardId, user);
return ResponseEntity.noContent().build();
}
@PostMapping("/{boardId}/members")
public ResponseEntity<Board> addMemberToBoard(@PathVariable Long boardId,
@RequestBody AddMemberRequest request,
Authentication authentication) {
User user = (User) authentication.getPrincipal();
Board updatedBoard = boardService.addMemberToBoard(boardId, request.getEmail(), user);
return ResponseEntity.ok(updatedBoard);
}
@GetMapping("/{boardId}")
public ResponseEntity<Board> getBoard(@PathVariable Long boardId, 
Authentication authentication) {
User user = (User) authentication.getPrincipal();
Board board = boardService.getBoardForUser(boardId, user);
return ResponseEntity.ok(board);
}
}
@RestController
@RequestMapping("/api/cards")
public class CardController {
@Autowired
private CardService cardService;
@PostMapping
public ResponseEntity<Card> createCard(@RequestBody CreateCardRequest request,
Authentication authentication) {
User user = (User) authentication.getPrincipal();
Card card = new Card();
card.setTitle(request.getTitle());
card.setDescription(request.getDescription());
Card createdCard = cardService.createCard(card, request.getListId(), user);
return ResponseEntity.status(HttpStatus.CREATED).body(createdCard);
}
@PutMapping("/{cardId}")
public ResponseEntity<Card> updateCard(@PathVariable Long cardId,
@RequestBody Card cardUpdate,
Authentication authentication) {
User user = (User) authentication.getPrincipal();
Card updatedCard = cardService.updateCard(cardId, cardUpdate, user);
return ResponseEntity.ok(updatedCard);
}
@PostMapping("/{cardId}/move")
public ResponseEntity<Card> moveCard(@PathVariable Long cardId,
@RequestBody MoveCardRequest request,
Authentication authentication) {
User user = (User) authentication.getPrincipal();
Card movedCard = cardService.moveCard(cardId, request.getTargetListId(), 
request.getNewPosition(), user);
return ResponseEntity.ok(movedCard);
}
@PostMapping("/{cardId}/assignees")
public ResponseEntity<Card> assignUserToCard(@PathVariable Long cardId,
@RequestBody AssignUserRequest request,
Authentication authentication) {
User user = (User) authentication.getPrincipal();
Card updatedCard = cardService.assignUserToCard(cardId, request.getUserId(), user);
return ResponseEntity.ok(updatedCard);
}
}
@RestController
@RequestMapping("/api/comments")
public class CommentController {
@Autowired
private CommentService commentService;
@PostMapping
public ResponseEntity<Comment> addComment(@RequestBody AddCommentRequest request,
Authentication authentication) {
User user = (User) authentication.getPrincipal();
Comment comment = commentService.addCommentToCard(request.getCardId(), 
request.getContent(), user);
return ResponseEntity.status(HttpStatus.CREATED).body(comment);
}
@DeleteMapping("/{commentId}")
public ResponseEntity<Void> deleteComment(@PathVariable Long commentId,
Authentication authentication) {
User user = (User) authentication.getPrincipal();
commentService.deleteComment(commentId, user);
return ResponseEntity.noContent().build();
}
}

DTO Classes

public class CreateCardRequest {
private String title;
private String description;
private Long listId;
// Constructors, getters, setters
}
public class MoveCardRequest {
private Long targetListId;
private Integer newPosition;
// Constructors, getters, setters
}
public class AddMemberRequest {
private String email;
// Constructors, getters, setters
}
public class AssignUserRequest {
private Long userId;
// Constructors, getters, setters
}
public class AddCommentRequest {
private Long cardId;
private String content;
// Constructors, getters, setters
}

Security Configuration

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtAuthenticationEntryPoint unauthorizedHandler;
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors().and()
.csrf().disable()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/css/**", "/js/**", "/images/**").permitAll()
.requestMatchers("/websocket/**").permitAll()
.anyRequest().authenticated()
);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
}
};
}
}
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider tokenProvider;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(request);
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Long userId = tokenProvider.getUserIdFromJWT(jwt);
UserDetails userDetails = userDetailsService.loadUserByUsername(userId.toString());
UsernamePasswordAuthenticationToken authentication = 
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) {
logger.error("Could not set user authentication in security context", ex);
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}

WebSocket Configuration for Real-time Updates

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/websocket")
.setAllowedOriginPatterns("*")
.withSockJS();
}
}
@Service
public class WebSocketNotificationService {
@Autowired
private SimpMessagingTemplate messagingTemplate;
public void notifyBoardUpdate(Long boardId, Object message) {
messagingTemplate.convertAndSend("/topic/board/" + boardId, message);
}
public void notifyCardUpdate(Long cardId, Object message) {
messagingTemplate.convertAndSend("/topic/card/" + cardId, message);
}
}

Frontend with Thymeleaf

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Task Manager</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
.board-header { background-color: #026aa7; color: white; }
.list-container { background-color: #ebecf0; border-radius: 3px; }
.card-item { background-color: white; border-radius: 3px; box-shadow: 0 1px 0 rgba(9,30,66,.25); }
.card-item:hover { background-color: #f4f5f7; }
.dragging { opacity: 0.5; }
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark board-header">
<div class="container-fluid">
<a class="navbar-brand" th:href="@{/}">Task Manager</a>
<div class="navbar-nav ms-auto">
<span class="navbar-text me-3" th:text="${user.username}">User</span>
<a class="btn btn-outline-light btn-sm" th:href="@{/logout}">Logout</a>
</div>
</div>
</nav>
<div class="container-fluid mt-3" id="app">
<!-- Boards List -->
<div th:if="${board == null}">
<div class="row">
<div class="col-md-4 mb-3" th:each="board : ${boards}">
<div class="card">
<div class="card-body">
<h5 class="card-title" th:text="${board.title}">Board Title</h5>
<p class="card-text" th:text="${board.description}">Board Description</p>
<a th:href="@{/board/{id}(id=${board.id})}" class="btn btn-primary">Open Board</a>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card">
<div class="card-body text-center">
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#createBoardModal">
<i class="fas fa-plus"></i> Create New Board
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Single Board View -->
<div th:if="${board != null}">
<div class="d-flex align-items-center mb-3">
<h2 th:text="${board.title}" class="me-3"></h2>
<button class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addListModal">
<i class="fas fa-plus"></i> Add List
</button>
</div>
<div class="board-lists d-flex overflow-auto pb-3">
<div th:each="list : ${board.lists}" class="list-container me-3" style="min-width: 300px;">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" th:text="${list.title}">List Title</h6>
<div class="dropdown">
<button class="btn btn-sm" type="button" data-bs-toggle="dropdown">
<i class="fas fa-ellipsis-h"></i>
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" th:href="@{/api/lists/{id}(id=${list.id})}" 
onclick="return confirm('Delete this list?')">Delete List</a></li>
</ul>
</div>
</div>
<div class="card-body">
<div th:each="card : ${list.cards}" class="card-item mb-2 p-2" 
th:attr="data-card-id=${card.id}">
<div th:text="${card.title}" class="fw-bold"></div>
<div th:if="${card.dueDate}" class="text-muted small">
<i class="fas fa-clock"></i>
<span th:text="${#temporals.format(card.dueDate, 'MMM dd')}"></span>
</div>
<div th:if="${not card.assignees.empty}" class="mt-1">
<span th:each="assignee : ${card.assignees}" 
class="badge bg-primary me-1" 
th:text="${assignee.username}"></span>
</div>
</div>
<button class="btn btn-outline-secondary btn-sm w-100" 
data-bs-toggle="modal" 
data-bs-target="#addCardModal"
th:attr="data-list-id=${list.id}">
<i class="fas fa-plus"></i> Add Card
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create Board Modal -->
<div class="modal fade" id="createBoardModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form th:action="@{/api/boards}" method="post">
<div class="modal-header">
<h5 class="modal-title">Create New Board</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Board Title</label>
<input type="text" class="form-control" name="title" required>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea class="form-control" name="description" rows="3"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Color</label>
<input type="color" class="form-control" name="color" value="#026aa7">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Create Board</button>
</div>
</form>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

JavaScript for Drag & Drop

// static/js/app.js
document.addEventListener('DOMContentLoaded', function() {
// Initialize Sortable for lists
const boardLists = document.querySelector('.board-lists');
if (boardLists) {
new Sortable(boardLists, {
group: 'lists',
animation: 150,
ghostClass: 'dragging',
onEnd: function(evt) {
// Handle list reordering
console.log('List moved', evt);
}
});
// Initialize Sortable for cards in each list
document.querySelectorAll('.card-body').forEach(function(listElement) {
new Sortable(listElement, {
group: 'cards',
animation: 150,
ghostClass: 'dragging',
onEnd: function(evt) {
const cardId = evt.item.dataset.cardId;
const fromListId = evt.from.parentElement.querySelector('.btn').dataset.listId;
const toListId = evt.to.parentElement.querySelector('.btn').dataset.listId;
const newPosition = evt.newIndex;
// Send move request to server
fetch(`/api/cards/${cardId}/move`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
targetListId: toListId,
newPosition: newPosition
})
});
}
});
});
}
// Add card modal setup
const addCardModal = document.getElementById('addCardModal');
if (addCardModal) {
addCardModal.addEventListener('show.bs.modal', function(event) {
const button = event.relatedTarget;
const listId = button.dataset.listId;
const modalForm = addCardModal.querySelector('form');
modalForm.action = `/api/cards?listId=${listId}`;
});
}
// WebSocket connection for real-time updates
connectWebSocket();
});
function connectWebSocket() {
const socket = new SockJS('/websocket');
const stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
console.log('Connected: ' + frame);
// Subscribe to board updates
const boardId = window.location.pathname.split('/').pop();
if (boardId) {
stompClient.subscribe('/topic/board/' + boardId, function(message) {
const update = JSON.parse(message.body);
handleBoardUpdate(update);
});
}
});
function handleBoardUpdate(update) {
// Refresh the board or specific components
location.reload(); // Simple implementation - could be more granular
}
}

Application Properties

# application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/taskmanager
spring.datasource.username=postgres
spring.datasource.password=password
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
server.port=8080
# File upload
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
# JWT Secret
app.jwt.secret=your-jwt-secret-key
app.jwt.expiration=86400000
# Logging
logging.level.com.taskmanager=DEBUG

Running the Application

@SpringBootApplication
public class TaskManagerApplication {
public static void main(String[] args) {
SpringApplication.run(TaskManagerApplication.class, args);
}
@Bean
public CommandLineRunner demo(UserRepository userRepository, 
PasswordEncoder passwordEncoder) {
return (args) -> {
// Create demo user if not exists
if (userRepository.findByEmail("[email protected]").isEmpty()) {
User demoUser = new User();
demoUser.setEmail("[email protected]");
demoUser.setUsername("demo");
demoUser.setPassword(passwordEncoder.encode("password"));
userRepository.save(demoUser);
}
};
}
}

Features Implemented

  • ✅ User authentication and authorization
  • ✅ Board creation and management
  • ✅ Lists within boards
  • ✅ Cards with drag & drop functionality
  • ✅ Card assignments and due dates
  • ✅ Comments on cards
  • ✅ Real-time updates via WebSocket
  • ✅ File attachments
  • ✅ Activity tracking
  • ✅ Responsive UI with Bootstrap

This Trello clone provides a solid foundation for a task management tool with room for additional features like labels, card covers, advanced filtering, and team management capabilities.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper