google-site-verification: google61fe8ba583a51912.html
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