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.