이전 진행 상황
- 공통 페이지 개발
- user 페이지 개발
마지막 파트인 post와 에러 페이지에 대한 내용입니다.
- post
- error
Post
게시글과 관련된 페이지들입니다. 인덱스 페이지인 게시글 목록, 게시글 작성 및 수정 그리고 게시글에 대한 내용을 조회하는 게시글 조회 페이지로 이루어져 있습니다.
- 게시글 목록
- 게시글 작성
- 게시글 조회
- 게시글 수정
게시글 목록
인덱스 페이지로 사용되는 게시글 목록 페이지입니다. 페이지 접근 시 기본적으로 일반 게시글 목록이 출력되며 탭을 통해 각 종류의 게시글 목록을 얻을 수 있습니다.
스크롤을 내리게 되면 아래의 검색창이 나오게 됩니다.
검색의 경우 제목, 내용, 작성자, 제목 + 내용으로 검색이 가능하며 검색된 경우 게시글 목록 위의 텍스트가 Search Post로 변경됩니다.
페이징의 경우 게시글 10개 단위로 이루어져 있으며 다음 페이지로 넘기기 위한 이전, 다음 버튼과 다음 페이지 그룹에 접근할 수 있는 화살표 버튼이 존재합니다.
>> 버튼으로 다음 페이지 그룹에 접근이 가능하고 원하는 페이지를 누르는 경우 해당 페이지로 이동이 발생합니다. 아래는 thymeleaf 템플릿의 main 블록 아래 코드로 게시글 목록은 프래그먼트로 구현되어 삽입되었습니다.
<main layout:fragment="content">
<div class="tab-container">
<div class="tab-wrapper">
<div class="tab active" data-type="NORMAL">Normal</div>
<div class="tab" data-type="NOTICE">Notice</div>
<div class="tab" data-type="POPULAR">Popular</div>
</div>
</div>
<div class="content-wrapper">
<div class="header-section">
<h2 class="post-type-title">Normal Post</h2>
<a th:href="@{/post/write}" class="write-button button">글쓰기</a>
</div>
<input type="hidden" id="currentPostType" value="NORMAL">
<input type="hidden" id="currentSearchType" value="">
<input type="hidden" id="currentKeyword" value="">
<div id="posts-fragment" th:insert="fragments/post/posts-fragment :: posts-fragment"></div>
<div class="search-section">
<div class="search-container">
<select id="searchType" class="search-dropdown">
<option value="TITLE">제목</option>
<option value="CONTENT">내용</option>
<option value="AUTHOR">작성자</option>
<option value="TITLE_CONTENT">제목 + 내용</option>
</select>
<input type="text" id="searchKeyword" class="search-input" placeholder="검색어를 입력하세요">
<button type="button" id="searchButton" class="search-button">
<i class="fas fa-search"></i>
</button>
</div>
</div>
</div>
</main>
아래는 삽입된 게시글 프래그먼트로 검색 상태의 페이지네이션과 일반 페이지네이션을 위한 코드가 나눠져 있습니다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<div th:fragment="posts-fragment">
<div class="posts-container">
<table class="posts-table">
<thead>
<tr>
<th class="no-column">No</th>
<th class="title-column">title</th>
<th class="author-column">author</th>
<th class="date-column">date</th>
<th class="recom-column">recom</th>
</tr>
</thead>
<tbody>
<tr th:each="post : ${posts}">
<td th:text="${post.id}"></td>
<td>
<a th:href="@{/post/{id}(id=${post.id})}"
th:text="${post.title}"
class="post-title clickable-text"></a>
</td>
<td th:text="${post.username}"></td>
<td th:text="${post.createdAt}"></td>
<td th:text="${post.recomCount}"></td>
</tr>
</tbody>
</table>
<div class="pagination" th:if="${totalPages > 0}">
<!-- 검색 상태일 때의 페이지네이션 -->
<th:block th:if="${searchType != null && keyword != null}">
<a th:if="${hasPrev}" class="page-button"
th:href="@{/posts/search/page(searchType=${searchType}, keyword=${keyword}, pageGroup=${(currentPage-2)/9})}">≪</a>
<a th:if="${hasPrev}" class="page-button"
th:href="@{/posts/search/page(searchType=${searchType}, keyword=${keyword}, page=${currentPage - 2})}"><</a>
<th:block th:each="pageNum : ${#numbers.sequence(startPage, endPage)}">
<a th:href="@{/posts/search/page(searchType=${searchType}, keyword=${keyword}, page=${pageNum - 1})}"
th:text="${pageNum}"
th:class="${pageNum == currentPage} ? 'page-number active' : 'page-number'"></a>
</th:block>
<a th:if="${hasNext}" class="page-button"
th:href="@{/posts/search/page(searchType=${searchType}, keyword=${keyword}, page=${currentPage})}">></a>
<a th:if="${hasNextGroup}" class="page-button"
th:href="@{/posts/search/page(searchType=${searchType}, keyword=${keyword}, pageGroup=${currentPage/9 + 1})}">≫</a>
</th:block>
<!-- 일반 상태일 때의 페이지네이션 -->
<th:block th:unless="${searchType != null && keyword != null}">
<a th:if="${hasPrev}" class="page-button"
th:href="@{/posts/page(type=${postType}, pageGroup=${(currentPage-2)/9})}">≪</a>
<a th:if="${hasPrev}" class="page-button"
th:href="@{/posts/page(type=${postType}, page=${currentPage - 2})}"><</a>
<th:block th:each="pageNum : ${#numbers.sequence(startPage, endPage)}">
<a th:href="@{/posts/page(type=${postType}, page=${pageNum - 1})}"
th:text="${pageNum}"
th:class="${pageNum == currentPage} ? 'page-number active' : 'page-number'"></a>
</th:block>
<a th:if="${hasNext}" class="page-button"
th:href="@{/posts/page(type=${postType}, page=${currentPage})}">></a>
<a th:if="${hasNextGroup}" class="page-button"
th:href="@{/posts/page(type=${postType}, pageGroup=${currentPage/9 + 1})}">≫</a>
</th:block>
</div>
</div>
</div>
</html>
해당 페이지의 ts 코드로는 탭 전환, 페이지네이션, 검색 이벤트에 대한 코드가 존재하고 그중 검색 이벤트에 대한 코드만 가져와봤습니다.
// 검색 이벤트 처리
const handleSearch = async () => {
const keyword = searchInput?.value.trim();
const searchType = searchDropdown?.value;
if (!keyword) {
alert('검색어를 입력해주세요.');
return;
}
// 검색 상태 저장
currentSearchType.value = searchType;
currentKeyword.value = keyword;
// 타이틀 변경
if (postTypeTitle) {
postTypeTitle.textContent = 'Search Post';
}
try {
const response = await fetch(`/posts/search?searchType=${searchType}&keyword=${encodeURIComponent(keyword)}`, {
headers: {
'Accept': 'text/html',
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const html = await response.text();
if (postsFragment && html.trim()) {
postsFragment.innerHTML = html;
}
} catch (error) {
console.error('Error searching posts:', error);
}
};
searchButton?.addEventListener('click', () => {
handleSearch();
});
// 검색어 입력 후 엔터 키 이벤트
searchInput?.addEventListener('keypress', (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
});
게시글 작성
게시글 작성에 대한 페이지입니다. 회원과 비회원 게시글 작성의 차이는 displayName과 password 필드의 표시 유무 정도이며 매너저 이상의 회원이 작성 시 공지 타입 라디오 버튼이 표시되게 됩니다.
비회원의 경우 Nickname 필드와 password 필드가 표시되고 required 속성으로 필수 입력 필드로 생성됩니다.
비회원 그리고 일반 회원은 일반글 타입으로 고정되어 있으며 내용과 제목을 입력하여 작성이 가능합니다. 작성 버튼 옆 이미지 버튼을 클릭할 경우 게시글 내 이미지를 삽입할 수 있으며 삽입이 성공되면 내용 필드 위에 삽입된 임시 이미지를 볼 수 있고 해당 이미지의 임시 경로가 본문에 삽입되게 됩니다.
삽입된 임시 이미지는 작성 버튼 클릭 시 컨트롤러에서 정식 이미지로 변환처리 하게 됩니다.
아래는 html의 main 블록 코드입니다.
<main layout:fragment="content">
<div class="write-container">
<h1 class="write-title">Write</h1>
<form th:action="@{/post/write}" method="post" th:object="${request}" id="writeForm" autocomplete="off">
<!-- 비로그인 시에만 표시되는 필드 -->
<th:block th:if="${#authentication == null || #authentication instanceof T(org.springframework.security.authentication.AnonymousAuthenticationToken)}">
<div class="input-group">
<input type="text" th:field="*{displayName}" placeholder="Nickname" class="write-input" required>
</div>
<div class="input-group">
<input type="password" th:field="*{password}" placeholder="password" class="write-input" required>
</div>
</th:block>
<!-- 게시글 타입 선택 -->
<div class="post-type">
<input type="radio" id="normal" th:field="*{type}" value="NORMAL" th:checked="*{type == null or type == 'NORMAL'}">
<label for="normal">일반</label>
<th:block th:if="${#authentication != null && (#authentication.authorities.![authority].contains('ROLE_MANAGER') || #authentication.authorities.![authority].contains('ROLE_ADMIN'))}">
<input type="radio" id="notice" th:field="*{type}" value="NOTICE">
<label for="notice">공지</label>
</th:block>
</div>
<!-- 제목 입력 -->
<div class="input-group">
<input type="text" th:field="*{title}" placeholder="title" class="write-input title-input" required>
</div>
<!-- 내용 입력 -->
<div class="input-group">
<textarea th:field="*{content}" class="write-textarea" placeholder="내용을 입력하세요" required></textarea>
</div>
<!-- 버튼 그룹 -->
<div class="button-group">
<a th:href="@{/post/write/cancel}" class="cancel-button">cancel</a>
<div class="right-buttons">
<label for="imageUpload" class="image-button">
Image <i class="fas fa-image"></i>
</label>
<input type="file" id="imageUpload" accept="image/png,image/jpeg" style="display: none">
<button type="submit" class="write-button">write</button>
</div>
</div>
</form>
</div>
</main>
아래는 게시글 작성의 ts 코드입니다.
document.addEventListener('DOMContentLoaded', () => {
const imageUpload = document.getElementById('imageUpload') as HTMLInputElement;
const writeForm = document.getElementById('writeForm') as HTMLFormElement;
const textarea = document.querySelector('.write-textarea') as HTMLTextAreaElement;
// 이미지 미리보기 컨테이너 생성
const previewContainer = document.createElement('div');
previewContainer.className = 'image-preview-container';
previewContainer.style.marginBottom = '20px';
textarea.parentNode?.insertBefore(previewContainer, textarea);
// 이미지 업로드 처리
imageUpload?.addEventListener('change', async (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
// 파일 크기 체크 (4MB)
if (file.size > 4 * 1024 * 1024) {
alert('이미지의 크기가 너무 큽니다. (최대 4MB)');
return;
}
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/photos/temp-upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Upload failed');
}
const imagePath = await response.text();
// 이미지 미리보기 생성
const previewImg = document.createElement('img');
previewImg.src = imagePath;
previewImg.style.maxWidth = '300px';
previewImg.style.marginRight = '10px';
previewImg.style.marginBottom = '10px';
previewContainer.appendChild(previewImg);
// 텍스트 영역에 마크다운 이미지 문법 추가
if (textarea) {
const cursorPos = textarea.selectionStart;
const textBefore = textarea.value.substring(0, cursorPos);
const textAfter = textarea.value.substring(cursorPos);
textarea.value = textBefore + `` + textAfter;
}
} catch (error) {
console.error('Error uploading image:', error);
alert('이미지 업로드에 실패했습니다.');
}
});
});
게시글 조회
게시글 조회 페이지입니다. 작성된 게시글의 내용을 확인할 수 있고 해당 게시글에 작성된 댓글 조회와 작성이 가능한 페이지입니다.
댓글은 별도의 프래그먼트로 존재하고 15개 단위로 페이징 처리 됩니다. 댓글 작성의 경우 비회원인 경우 Nickname과 Password 필드가 표시되고 회원의 경우 표시되지 않습니다.
댓글을 작성하는 경우 회원이라면 회원의 username 비회원이라면 Nickname 박스에 적었던 이름으로 표시되게 됩니다.
원본 댓글의 내용 텍스트를 누르게 되면 대댓글 작성모드가 실행되고 현재 어떤 댓글에 대한 대댓글 작성 중인지 알 수 있습니다.
작성된 대댓글은 화살표 표시와 함께 들여 쓰기 된 상태로 표시됩니다.
추천/비추천 버튼을 누르는 경우 추천/비추천 기능이 처리되며 회원의 경우 회원 아이디를 기준으로 24시간 내 같은 게시글 추천/비추천이 불가능하고 비회원의 경우 세션을 기준으로 처리됩니다. 해당 기능은 레디스를 통해
교차 검증을 통해 한 게시글에 대해 하나의 추천 또는 비추천만 가능하기에 이미 추천을 한 경우에 비추천도 불가능합니다.
추천수가 10개 이상이 되면 해당 글은 인기글로 넘어가게 됩니다.
게시글에 이미지가 포함되어 있는 경우입니다.
수정과 삭제의 경우 회원 인증에 따라 처리가 달라집니다. 비회원 게시글에서의 수정과 삭제 접근 경우 해당 게시글 권한을 인증할 비밀번호를 입력하는 창이 뜨게 됩니다.
인증에 성공하는 경우 게시글 수정 페이지로 연결되고 인증에 실패하는 경우 실패 알림이 뜨게 됩니다.
비회원 댓글의 경우에도 동일하게 처리됩니다. 회원 게시글의 경우 글을 작성한 회원이 아닌 다른 사용자가 접근하는 경우 권한 인증에 실패하게 됩니다.
댓글 수정의 경우 인증에 성공하게 되면 댓글 수정을 위한 프래그먼트를 얻게 되고 기존 댓글의 내용을 수정할 수 있습니다.
아래는 게시글 조회 페이지의 html 메인 블록 코드입니다.
<main layout:fragment="content">
<div class="post-container">
<input type="hidden" id="userId" th:value="${user}">
<h1 class="post-title" th:text="${post.title}"></h1>
<div class="post-header">
<div class="post-info">
<span class="author" th:text="${post.username}"></span>
<span class="comment-count" th:text="|댓글 ${commentCount}|"></span>
</div>
<div class="post-meta">
<span class="date" th:text="${post.createdAt}"></span>
<span class="edit-button clickable-text">수정</span>
<span class="post-delete clickable-text">삭제</span>
</div>
</div>
<div class="post-content" th:utext="${post.content}"></div>
<!-- 댓글 수와 추천/비추천 부분 -->
<div class="comments-header">
<span class="total-comments" th:text="|댓글 ${commentCount}|"></span>
<div class="recommend-buttons">
<span class="recommend clickable-text">
추천 <span th:text="${post.recomCount}"></span> 👍
</span>
<span class="unrecommend clickable-text">
비추천 <span th:text="${post.notRecomCount}"></span> 👎
</span>
</div>
</div>
<!-- 댓글 프래그먼트 -->
<div id="comments-fragment" th:insert="fragments/comment/comments-fragment :: comments-fragment"></div>
<!-- 댓글 작성 폼 -->
<div class="comment-write">
<div id="reply-info" style="display: none;" class="reply-info">
<span class="reply-to"></span>
<button type="button" class="cancel-reply clickable-text">취소</button>
</div>
<form th:action="@{/post/{postId}/comment/write(postId=${post.id})}" method="post" th:object="${commentRequest}">
<input type="hidden" th:field="*{parentId}" id="parentCommentId">
<th:block th:if="${#authentication == null || #authentication instanceof T(org.springframework.security.authentication.AnonymousAuthenticationToken)}">
<div class="input-group">
<input type="text" th:field="*{displayName}" placeholder="Nickname" class="comment-input" required>
<input type="password" th:field="*{password}" placeholder="Password" class="comment-input" required>
</div>
</th:block>
<textarea th:field="*{content}" class="comment-textarea" placeholder="댓글을 입력하세요" required></textarea>
<button type="submit" class="comment-button">write</button>
</form>
</div>
</div>
</main>
댓글 프래그먼트를 포함하고 있고 아래는 댓글 프래그먼트의 코드입니다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<div th:fragment="comments-fragment">
<div class="comments-container">
<div class="comments-list">
<!-- 원본 댓글만 먼저 순회 -->
<div th:each="comment : ${comments}" th:if="${comment.depth == 0}" class="comment"
th:data-comment-id="${comment.id}"
th:data-user-id="${comment.userId}">
<div class="comment-wrapper">
<div class="comment-content">
<span class="comment-author" th:text="${comment.username}"></span>
<span class="comment-text"
th:text="${comment.content}"
th:classappend="${!comment.isDeleted ? 'clickable-text' : ''}"></span>
<span class="comment-date" th:text="${comment.createdAt}"></span>
<th:block th:if="${!comment.isDeleted}">
<span class="comment-edit clickable-text">수정</span>
<span class="comment-delete clickable-text">삭제</span>
</th:block>
</div>
<div class="edit-form-container" style="display: none;"></div>
<!-- 대댓글 표시 -->
<div th:each="reply : ${comments}"
th:if="${reply.depth == 1 && reply.parentId == comment.id}"
class="reply comment"
th:data-comment-id="${reply.id}"
th:data-user-id="${reply.userId}">
<span class="reply-arrow">↳</span>
<div class="comment-content">
<span class="comment-author" th:text="${reply.username}"></span>
<span class="comment-text" th:text="${reply.content}"></span>
<span class="comment-date" th:text="${reply.createdAt}"></span>
<th:block th:if="${!reply.isDeleted}">
<span class="comment-edit clickable-text">수정</span>
<span class="comment-delete clickable-text">삭제</span>
</th:block>
</div>
<div class="edit-form-container" style="display: none;"></div>
</div>
</div>
</div>
</div>
<div class="pagination" th:if="${totalPages > 0}">
<a th:if="${hasPrev}" class="page-button"
th:href="@{/post/{postId}/comments/page(postId=${post.id}, pageGroup=${(currentPage-2)/9})}"><<</a>
<a th:if="${hasPrev}" class="page-button"
th:href="@{/post/{postId}/comments/page(postId=${post.id}, page=${currentPage - 2})}"><</a>
<th:block th:each="pageNum : ${#numbers.sequence(startPage, endPage)}">
<a th:href="@{/post/{postId}/comments/page(postId=${post.id}, page=${pageNum - 1})}"
th:text="${pageNum}"
th:class="${pageNum == currentPage} ? 'page-number active' : 'page-number'"></a>
</th:block>
<a th:if="${hasNext}" class="page-button"
th:href="@{/post/{postId}/comments/page(postId=${post.id}, page=${currentPage})}">></a>
<a th:if="${hasNextGroup}" class="page-button"
th:href="@{/post/{postId}/comments/page(postId=${post.id}, pageGroup=${currentPage/9 + 1})}">>></a>
</div>
</div>
</div>
</html>
댓글 프래그먼트의 경우 수정 폼을 위한 프래그먼트를 평소에 표시되지 않는 상태로 포함하고 있습니다. 아래는 수정 프래그먼트 코드입니다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<div th:fragment="comment-edit">
<style>
.comment-edit-form {
margin: 10px 0;
padding: 10px;
border: 1px solid #000;
border-radius: 5px;
margin-left: 40px;
width: calc(100% - 40px);
}
textarea {
width: calc(100% - 20px);
height: 80px;
padding: 10px;
border: 1px solid #000;
border-radius: 5px;
resize: vertical;
font-family: 'Kreon', serif;
margin-bottom: 10px;
overflow-y: auto;
}
button {
padding: 5px 15px;
border: 1px solid #000;
border-radius: 5px;
background: none;
cursor: pointer;
font-family: 'Kreon', serif;
margin-right: 10px;
}
</style>
<form class="comment-edit-form" th:action="@{/post/{postId}/comment/{commentId}/edit(postId=${postId},commentId=${commentEditRequest.id})}" method="post">
<textarea th:text="${commentEditRequest.content}" name="content" required></textarea>
<button type="submit">수정</button>
<button type="button" class="cancel-edit">취소</button>
</form>
</div>
</html>
ts 코드의 경우 게시글에 대한 로직과 여러 프래그먼트에 대한 로직이 같이 존재하다 보니 코드가 길어 추천/비추천에 대한 부분만 가져왔습니다.
// 추천/비추천 버튼 이벤트
document.addEventListener('click', async (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.matches('.recommend, .unrecommend')) return;
const isRecommend = target.classList.contains('recommend');
const postId = window.location.pathname.split('/').pop();
try {
const response = await fetch(`/api/post/${postId}/${isRecommend ? 'recommend' : 'not-recommend'}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
const result = await response.json();
if (!response.ok) {
alert(result.message);
return;
}
const countElement = target.querySelector('span');
if (countElement) {
countElement.textContent = isRecommend ? result.recomCount : result.notRecomCount;
}
alert(result.message);
} catch (error) {
console.error('Error processing recommendation:', error);
alert('처리 중 오류가 발생했습니다.');
}
});
게시글 수정
게시글 조회 화면에서 얻어지는 게시글 수정 페이지입니다. 작성된 게시글을 수정하는 페이지로 내용과 제목에 대한 수정만 가능합니다. 매니저 이상 회원의 경우 글 타입 변경이 가능합니다.
수정의 경우 게시글 작성과 유사한 형태를 가지고 있습니다. 아래는 메인 블록 코드입니다.
<main layout:fragment="content">
<div class="edit-container">
<h1 class="edit-title">Edit</h1>
<form th:action="@{/post/{postId}/edit(postId=${postId})}" method="post" th:object="${request}" id="editForm" autocomplete="off">
<!-- 게시글 타입 선택 -->
<div class="post-type">
<input type="radio" id="normal" th:field="*{type}" value="NORMAL" th:checked="*{type == null or type == 'NORMAL'}">
<label for="normal">일반</label>
<th:block th:if="${#authentication != null && (#authentication.authorities.![authority].contains('ROLE_MANAGER') || #authentication.authorities.![authority].contains('ROLE_ADMIN'))}">
<input type="radio" id="notice" th:field="*{type}" value="NOTICE">
<label for="notice">공지</label>
</th:block>
</div>
<!-- 제목 입력 -->
<div class="input-group">
<input type="text" th:field="*{title}" placeholder="title" class="edit-input title-input" required>
</div>
<!-- 내용 입력 -->
<div class="input-group">
<textarea th:field="*{content}" class="edit-textarea" placeholder="내용을 입력하세요" required></textarea>
</div>
<!-- 버튼 그룹 -->
<div class="button-group">
<a th:href="@{/post/{postId}/edit/cancel(postId=${postId})}" class="cancel-button">cancel</a>
<div class="right-buttons">
<label for="imageUpload" class="image-button">
Image <i class="fas fa-image"></i>
</label>
<input type="file" id="imageUpload" accept="image/png,image/jpeg" style="display: none">
<button type="submit" class="edit-button">edit</button>
</div>
</div>
</form>
</div>
</main>
ts 코드는 게시글 작성과 거의 동일하기에 생략하였습니다.
'개인 프로젝트 > Toy & Side' 카테고리의 다른 글
[TOY] 작동 테스트 (0) | 2025.01.09 |
---|---|
[TOY] 개발 - 프론트엔드(User, Admin) (1) | 2025.01.05 |
[TOY] 개발 - 수정 (0) | 2025.01.04 |
[TOY] 개발 - 통합 테스트 (0) | 2024.12.09 |
[TOY] 개발 - Adapters(Comment, Photo) (0) | 2024.12.05 |