개인 프로젝트/Toy & Side

[TOY] 개발 - 프론트엔드(User, Admin)

kwang2134 2025. 1. 5. 17:02
728x90
반응형
728x90

이전 진행 상황

  • admin 관련 기능 추가
  • 도커 의존성 추가
  • ts 컴파일 추가
  • 기타 수정

프론트엔드에 대한 지식이 부족하여 생성형 AI를 사용해 코드를 생성하고 다듬었습니다. 기존 bootstrap을 사용하여 간단하게 진행하려던 계획에서 figma를 통해 페이지를 디자인 한 김에 해당 디자인을 토대로 생성형 AI에게 코드를 생성하여 진행하였습니다. 코드들에 대한 깊은 지식은 부족하다고 생각되어 코드보다는 완성된 화면과 thymeleaf가 적용된 html 파일 위주로 작성하였습니다. 내용이 많아 공통부분과 user, admin에 대한 부분과 post에 대한 부분으로 문서를 나눴습니다.

  • common
  • user

Common

대부분의 페이지에 삽입될 헤더와 푸터입니다. 프래그먼트 형태로 layout에 삽입되어 모든 페이지에 사용되게 됩니다.


  • header
  • footer

header

기본적으로 헤더는 메인 index 페이지로 돌아갈 수 있는 홈페이지 아이콘이 왼쪽에 위치하고 오른쪽엔 로그인 버튼과 회원가입 버튼이 존재하게 됩니다. 로그인과 회원가입 페이지를 제외한 모든 페이지에 해당 헤더가 사용되게 됩니다. 

기본 header 로그인 전

 

로그인 성공 시 로그인과 회원가입 텍스트가 존재하던 자리에 로그인한 회원 정보와 마이페이지, 로그아웃 텍스트가 위치하게 됩니다.

기본 header 로그인 후

로그인, 회원가입, 마이페이지, 로그아웃의 경우 호버링 이벤트로 밑줄 텍스트가 되며 버튼 클릭 시 약간의 번짐 효과로 처리하여 사용성을 높였습니다. 

호버링 이벤트

다음은 로그인, 회원가입에 사용되는 헤더로 기본 헤더에서 로그인, 회원가입 텍스트가 보이지 않게 처리된 헤더입니다. 로그인 페이지의 경우 로그인 박스 아래 회원가입 버튼이 따로 존재하여 헤더에 텍스트가 필요하지 않다고 판단하였고 회원가입의 경우도 불 필요하다고 느껴 제거하였습니다.

로그인, 회원가입 페이지 전용

해당 html 코드로는 thymeleaf를 사용하여 회원이 로그인되어 있는 경우와 로그인되어 있지 않은 경우를 구분하여 출력할 텍스트를 결정하고 특정 페이지(로그인, 회원가입)의 경우 해당 요소를 표시하지 않도록 설정하였습니다.

	<!-- 로그인/회원가입 페이지가 아닐 때만 표시 -->
        <div class="header-menu"
             th:if="${!#strings.equals(currentUri, '/user/login') &&
             !#strings.equals(currentUri, '/user/signup')}">
            <!-- 비로그인 상태 -->
            <div class="auth-buttons" th:if="${#authentication == null || #authentication instanceof T(org.springframework.security.authentication.AnonymousAuthenticationToken)}">
                <a th:href="@{/user/login}" class="clickable-text">로그인</a>
                <a th:href="@{/user/signup}" class="clickable-text">회원가입</a>
            </div>
            <!-- 로그인 상태 -->
            <div class="user-menu" th:unless="${#authentication == null || #authentication instanceof T(org.springframework.security.authentication.AnonymousAuthenticationToken)}">
                <span class="welcome-message" th:text="${#authentication.name} + '님 환영합니다'"></span>
                <a th:href="@{/user/mypage}" class="clickable-text">마이페이지</a>
                <form th:action="@{/user/logout}" method="post" style="display: inline;">
                    <button type="submit" class="clickable-text">로그아웃</button>
                </form>
            </div>
        </div>

footer

모든 페이지 하단에 동일하게 적용될 푸터입니다. 푸터의 경우 구분 없이 모든 페이지에 동일하게 적용되며 왼쪽엔 티스토리와 깃허브 경로를 연결하였고 오른쪽엔 푸터용 아이콘을 삽입하였습니다.

푸터

 

푸터의 경우 특별한 th 처리 없이 제작되어 넘어가도록 하겠습니다.

base

헤더와 푸터를 쉽게 사용하기 위해 base가 될 html을 제작해 두고 layout으로 적용하여 제작하였습니다.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Story Board</title>

    <!-- 공통 폰트 -->
    <link href="https://fonts.googleapis.com/css2?family=Italianno&display=swap" rel="stylesheet">

    <!-- 공통 CSS -->
    <link rel="stylesheet" th:href="@{/css/common/common.css}">
    <link rel="stylesheet" th:href="@{/css/header/header.css}">
    <link rel="stylesheet" th:href="@{/css/footer/footer.css}">
    <style>
        /* 기본 스타일 초기화 */
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            min-height: 100vh;
            display: flex;
            flex-direction: column;
        }

        /* 헤더 아래 콘텐츠 여백 */
        main {
            margin-top: 80px;  /* 헤더 높이만큼 여백 */
            flex: 1;
        }

        /* 푸터 하단 고정 */
        footer {
            margin-top: auto;
        }
    </style>

    <!-- 페이지별 추가 CSS -->
    <th:block layout:fragment="css"></th:block>
</head>
<body>
<!-- 헤더 -->
<div th:replace="~{fragments/header/header :: header}"></div>

<!-- 메인 콘텐츠 -->
<main layout:fragment="content">
    <!-- 각 페이지의 내용이 여기에 들어감 -->
</main>

<!-- 푸터 -->
<div th:replace="~{fragments/footer/footer :: footer}"></div>

<!-- 공통 JavaScript -->
<!--<script th:src="@{/js/common.js}"></script>-->

<!-- 페이지별 추가 JavaScript -->
<th:block layout:fragment="script"></th:block>
</body>
</html>

User

회원에 대한 페이지로 로그인, 회원가입, 마이페이지로 이루어져 있고 마이페이지 내 탭을 통한 정보, 작성한 게시글, 정보 수정이 세분화되어 있습니다. 


  • 로그인
  • 회원가입
  • 마이페이지

로그인

로그인 화면으로 기존 디자인과 유사하게 제작되었습니다. 로그인 버튼, 회원가입 페이지 전환 버튼이 존재하고 아이디, 비밀번호 필드가 있습니다. 

로그인 화면

각 필드가 비어있는 경우 검증이 이뤄지게 되고 비어있는 필드의 테두리가 빨간색으로 변해 사용자에게 에러를 알리고 필드 아래 메시지를 띄웁니다. 

비밀번호 필드 단독 검증
아이디 필드 단독 검
필드 동시 검증

아래는 로그인 post 폼 요청을 보내는 부분입니다.

	    <form id="loginForm" th:action="@{/user/login}" method="post">
                <div class="input-group">
                    <input type="text" id="username" name="username"
                           placeholder="Login_ID" class="login-input"
                           th:value="${username}"
                           th:classappend="${errorMessage != null} ? 'error'">
                    <div class="error-message" th:classappend="${errorMessage != null} ? 'show'">
                        <span th:text="${errorMessage}"></span>
                    </div>

                </div>
                <div class="input-group">
                    <input type="password" id="password" name="password"
                           placeholder="Password" class="login-input"
                           th:classappend="${errorMessage != null} ? 'error'">
                    <div class="error-message" th:classappend="${errorMessage != null} ? 'show'">
                        <span th:text="${errorMessage}"></span>
                    </div>

                </div>
                <button type="submit" class="login-button button">Login</button>
                <div class="signup-link">
                    <a th:href="@{/user/signup}" class="clickable-text">Sign Up</a>
                </div>
            </form>

회원가입

회원가입에 사용되는 페이지로 로그인 화면의 Sign Up 버튼 클릭과 헤더의 회원가입 버튼을 통해 요청할 수 있습니다. 필드로는 아이디, 비밀번호, 비밀번호 확인, 사용자이름, 이메일(선택) 필드가 존재합니다. 이메일을 제외한 모든 다른 필드들은 필수 입력 사항으로 해당 필드들이 비어있는 경우 validation을 통해 에러를 출력합니다. 

회원가입

각 필드들에 대한 검증이 존재합니다. 로그인 아이디 필드의 경우 해당 필드 입력이 끝나게 되면 api 요청으로 입력된 로그인 아이디의 중복체크가 이루어지게 됩니다. 해당 필드가 비어있는 경우 또한 검증이 발생합니다.

아이디 사용가능
아이디 사용불가
Login_ID Not Null

비밀번호 필드의 경우 비밀번호와 비밀번호 확인 필드가 동일한 경우 초록색으로 표시하여 나타내게 됩니다. 만약 비밀번호 필드에 적힌 값과 비밀번호 확인 필드에 적힌 값이 동일하지 않는 경우 빨간색으로 표시하여 오류를 나타냅니다. 

비밀번호 1234 입력
비밀번호 확인 12345 입력
비밀번호 확인 실패
비밀번호 입력 성공

해당 필드 이벤트는 비밀번호 확인 필드 입력이 끝난 뒤 발생하게 됩니다. 비밀번호가 입력되지 않은 상태에서 제출이 발생하는 경우에도 검증이 일어나게 됩니다.

비밀번호 필드 Not Null

username 필드는 사용자의 별명을 저장하는 필드로 필수 입력 필드입니다. 회원 간 중복을 허용하고 해당 필드가 비어있는 경우 검증이 발생합니다. 

Username Not Null

이메일 필드의 경우 선택 사항으로 비워두게 되어도 정상 제출이 되나 이메일 형식에 맞지 않는 값이 입력된 경우에만 검증이 발생하게 됩니다. 

이메일 형식에 어긋남
이메일 형식 만족

아래는 회원가입을 요청하는 form post 요청입니다.

	    <form id="signupForm" th:action th:object="${userRequest}" method="post">
                <div class="input-group">
                    <input type="text" id="loginId" th:field="*{loginId}"
                           placeholder="Login_ID" class="signup-input"
                           th:classappend="${#fields.hasErrors('loginId')} ? 'error'">
                    <div class="message error" th:if="${#fields.hasErrors('loginId')}"
                         th:errors="*{loginId}"></div>
                    <div class="message"></div>
                </div>
                <div class="input-group">
                    <input type="password" id="password" th:field="*{password}"
                           placeholder="Password" class="signup-input"
                           th:classappend="${#fields.hasErrors('password')} ? 'error'">
                    <div class="message error" th:if="${#fields.hasErrors('password')}"
                         th:errors="*{password}"></div>
                </div>
                <div class="input-group">
                    <input type="password" id="confirmPassword" name="confirmPassword"
                           placeholder="Confirm Password" class="signup-input">
                    <div class="message"></div>
                </div>
                <div class="input-group">
                    <input type="text" id="username" th:field="*{username}"
                           placeholder="Username or Nickname" class="signup-input"
                           th:classappend="${#fields.hasErrors('username')} ? 'error'">
                    <div class="message error" th:if="${#fields.hasErrors('username')}"
                         th:errors="*{username}"></div>
                </div>
                <div class="input-group">
                    <input type="email" id="email" th:field="*{email}"
                           placeholder="E-mail address (Optional)" class="signup-input"
                           th:classappend="${#fields.hasErrors('email')} ? 'error'">
                    <div class="message error" th:if="${#fields.hasErrors('email')}"
                         th:errors="*{email}"></div>
                </div>
                <button type="submit" class="signup-button button">Sign Up</button>
            </form>

아래는 웹에서 처리를 담당하는 ts 코드입니다.

document.addEventListener('DOMContentLoaded', () => {
    const loginIdInput = document.getElementById('loginId') as HTMLInputElement;
    const passwordInput = document.getElementById('password') as HTMLInputElement;
    const confirmPasswordInput = document.getElementById('confirmPassword') as HTMLInputElement;
    const usernameInput = document.getElementById('username') as HTMLInputElement;
    const emailInput = document.getElementById('email') as HTMLInputElement;

    // 로그인 ID 중복 체크
    loginIdInput.addEventListener('blur', async () => {
        if (!loginIdInput.value.trim()) return;

        try {
            const response = await fetch(`/api/user/check-loginId?loginId=${encodeURIComponent(loginIdInput.value)}`, {
                method: 'GET',
                headers: {
                    'Content-Type': 'application/json'
                }
            });

            if (!response.ok) {
                throw new Error('Network response was not ok');
            }

            const data = await response.json();
            const messageDiv = loginIdInput.parentElement?.querySelector('.message');

            if (data.available) {
                loginIdInput.classList.remove('error');
                loginIdInput.classList.add('success');
                if (messageDiv) {
                    messageDiv.textContent = data.message;
                    messageDiv.classList.remove('error');
                    messageDiv.classList.add('success');
                }
            } else {
                loginIdInput.classList.remove('success');
                loginIdInput.classList.add('error');
                if (messageDiv) {
                    messageDiv.textContent = data.message;
                    messageDiv.classList.remove('success');
                    messageDiv.classList.add('error');
                }
            }
        } catch (error) {
            console.error('Error checking login ID:', error);
        }
    });

    // 비밀번호 확인 체크
    confirmPasswordInput.addEventListener('blur', () => {
        if (!passwordInput.value.trim()) return;

        const passwordMessageDiv = passwordInput.parentElement?.querySelector('.message');
        const confirmMessageDiv = confirmPasswordInput.parentElement?.querySelector('.message');
        const isMatch = passwordInput.value === confirmPasswordInput.value;

        [passwordInput, confirmPasswordInput].forEach(input => {
            input.classList.remove('error', 'success');
            input.classList.add(isMatch ? 'success' : 'error');
        });

        if (passwordMessageDiv) {
            passwordMessageDiv.textContent = '';
        }

        if (confirmMessageDiv) {
            confirmMessageDiv.textContent = isMatch ? '비밀번호가 일치합니다.' : '비밀번호가 일치하지 않습니다.';
            confirmMessageDiv.classList.remove('error', 'success');
            confirmMessageDiv.classList.add(isMatch ? 'success' : 'error');
        }
    });

    // 사용자 이름 체크
    usernameInput.addEventListener('blur', () => {
        if (!usernameInput.value.trim()) return;

        usernameInput.classList.remove('error');
        usernameInput.classList.add('success');
        const messageDiv = usernameInput.parentElement?.querySelector('.message');
        if (messageDiv) {
            messageDiv.textContent = '';
        }
    });

    // 이메일 유효성 체크
    emailInput.addEventListener('blur', () => {
        if (!emailInput.value.trim()) return;

        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        const isValid = emailRegex.test(emailInput.value);
        const messageDiv = emailInput.parentElement?.querySelector('.message');

        emailInput.classList.remove('error', 'success');
        emailInput.classList.add(isValid ? 'success' : 'error');

        if (messageDiv) {
            messageDiv.textContent = isValid ? '' : '올바른 이메일 형식이 아닙니다.';
            messageDiv.classList.remove('success', 'error');
            if (!isValid) messageDiv.classList.add('error');
        }
    });
});

마이페이지

여러 기능이 들어있는 마이페이지 코드입니다. 기본 마이페이지 접근 시 사용자의 정보를 나타내는 Info 탭을 띄우게 되고 각 탭들을 통해 다른 기능을 사용할 수 있습니다. 

마이페이지 Info

posts 탭에 접근하게 되면 해당 회원이 작성한 게시글을 리스트 형태로 확인할 수 있습니다. 해당 게시글 제목 클릭 시 작성된 게시글로 연결됩니다.

마이페이지 posts

edit 탭의 경우 회원 정보를 변경할 수 있는 탭입니다. 탭 접근 시 기존 회원 정보가 입력되어 있습니다. 이메일을 제외한 모든 필드는 필수 입력이기 때문에 공란으로 제출이 불가능합니다. 

마이페이지 edit

username 필드가 공란이 될 경우 검증이 발생하여 공란 표시를 하게 되고 각 필드의 required 속성으로 제출 시 웹에서 입력란 작성 요청 알림이 뜨게 됩니다.

검증
required

비밀번호의 경우 회원가입과 동일하게 확인 필드와 비밀번호 필드의 값이 같아야 넘어가게 됩니다.

비밀번호 일치

이메일 필드는 공란은 상관없으나 회원가입과 동일하게 입력된 경우 이메일 형식을 만족하여야 진행됩니다.

이메일 검증

마이페이지의 경우 탭마다 프래그먼트를 삽입한 형식으로 제작되어 기본적으로 Info 탭이 활성화된 상태로 렌더링 되고 탭 요청에 따라 상태를 전환하게 됩니다. 

	<div class="mypage-content">
            <div class="tab-container">
                <div class="tab" th:classappend="${activeTab == 'info' || activeTab == null} ? 'active'" data-tab="info">Info</div>
                <div class="tab" th:classappend="${activeTab == 'posts'} ? 'active'" data-tab="posts">posts</div>
                <div class="tab" th:classappend="${activeTab == 'edit'} ? 'active'" data-tab="edit">edit</div>
            </div>
            <div class="content-container">
                <div id="info-fragment" class="fragment" th:classappend="${activeTab == 'info' || activeTab == null} ? 'active'"
                     th:insert="fragments/user/info :: info-fragment"></div>
                <div id="edit-fragment" class="fragment" th:classappend="${activeTab == 'edit'} ? 'active'"
                     th:insert="fragments/user/edit :: edit-fragment"></div>
                <div id="posts-fragment" class="fragment" th:classappend="${activeTab == 'posts'} ? 'active'"
                     th:insert="fragments/user/posts :: posts-fragment"></div>
            </div>
        </div>

 ts 코드로는 각 요청에 대한 이벤트들이 정의되어 있습니다. 그중 탭 전환 이벤트의 내용만 가져와봤습니다.

    // 탭 전환 이벤트
    tabs.forEach(tab => {
        tab.addEventListener('click', () => {
            const tabId = tab.getAttribute('data-tab');

            tabs.forEach(t => t.classList.remove('active'));
            tab.classList.add('active');

            fragments.forEach(fragment => {
                if (fragment.id === `${tabId}-fragment`) {
                    fragment.classList.add('active');
                } else {
                    fragment.classList.remove('active');
                }
            });
        });
    });

Admin

회원의 권한을 관리자가 선택하도록 설계하였기 때문에 관리자에 대한 페이지를 개발하였습니다. 간단하게 관리 화면으로 들어가기 위한 관리자 로그인 화면과 회원 관리 화면으로 이루어져 있습니다. 관리자 페이지의 경우 헤더와 푸터가 필요하지 않다고 생각하여 사용하지 않았습니다.


  • 관리자 로그인
  • 회원 관리

관리자 로그인

관리 화면으로 들어가기 위한 관리자 로그인 페이지입니다. 유저가 사용하는 페이지에서 접근이 불가능하고 지정된 admin/login url을 통해 접근합니다. 관리자 기능에 대한 구현만을 목적으로 하였다 보니 기타 기능 없이 핵심 로그인 기능만 구현하였습니다. 스프링 시큐리티 로그인으로 넘겨 로그인 요청한 사용자가 db에 저장된 권한이 ADMIN인 경우 로그인이 성공되게 됩니다. 

관리자 로그인

디자인이나 기타 구조 자체는 회원의 로그인 화면을 토대로 제작되어 유사한 모습을 하고 있습니다.

회원 관리

관리자 권한을 인증하고 로그인 한 뒤 연결되는 페이지입니다. admin/manage로 인증된 사용자의 권한이 ADMIN인 경우에만 접근이 가능한 url입니다. 기본적으로 일반 유저 탭과 매니저 유저 탭으로 나눠져 있으며 일반 유저의 경우 매니저로 승급시키거나 차단할 수 있습니다. 차단의 경우 데이터베이스에 존재하는 유저의 데이터가 삭제됩니다. 

일반 유저

매니저 유저 탭으로 매니저의 권한을 가진 유저는 바로 차단할 수 없고 일반 유저로 강등만 시킬 수 있게 하였습니다. 즉 매니저인 유저를 차단하고자 하는 경우 일반 유저로 강등시킨 뒤 차단을 수행해야 합니다. 

매니저 유저

아래는 관리 화면에서 승급, 강등, 차단에 대한 처리를 위한 ts 코드입니다.

document.addEventListener('DOMContentLoaded', () => {
    // 탭 전환
    const tabs = document.querySelectorAll('.tab-button');
    tabs.forEach(tab => {
        tab.addEventListener('click', () => {
            const targetId = (tab as HTMLElement).dataset.tab;

            // 탭 활성화
            document.querySelectorAll('.tab-button').forEach(t => t.classList.remove('active'));
            tab.classList.add('active');

            // 컨테이너 전환
            document.querySelectorAll('.user-list-container').forEach(c => c.classList.remove('active'));
            document.getElementById(`${targetId}-users`)?.classList.add('active');
        });
    });

    // Appoint 버튼 이벤트
    document.querySelectorAll('.appoint-btn').forEach(btn => {
        btn.addEventListener('click', async (e) => {
            const userId = (e.target as HTMLElement).dataset.id;
            if (confirm('해당 사용자를 매니저로 승급하시겠습니까?')) {
                try {
                    const response = await fetch(`/api/admin/manage/appoint?id=${userId}`, {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json'
                        }
                    });
                    if (response.ok) {
                        alert('승급이 완료되었습니다.');
                        window.location.reload();
                    }
                } catch (error) {
                    alert('처리 중 오류가 발생했습니다.');
                }
            }
        });
    });

    // Ban 버튼 이벤트
    document.querySelectorAll('.ban-btn').forEach(btn => {
        btn.addEventListener('click', async (e) => {
            const userId = (e.target as HTMLElement).dataset.id;
            if (confirm('해당 사용자를 정말 차단하시겠습니까?')) {
                try {
                    const response = await fetch(`/api/admin/manage/ban?id=${userId}`, {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json'
                        }
                    });
                    if (response.ok) {
                        alert('차단이 완료되었습니다.');
                        window.location.reload();
                    }
                } catch (error) {
                    alert('처리 중 오류가 발생했습니다.');
                }
            }
        });
    });

    // Demote 버튼 이벤트
    document.querySelectorAll('.demote-btn').forEach(btn => {
        btn.addEventListener('click', async (e) => {
            const userId = (e.target as HTMLElement).dataset.id;
            if (confirm('해당 매니저를 일반 사용자로 강등하시겠습니까?')) {
                try {
                    const response = await fetch(`/api/admin/manage/demote?id=${userId}`, {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json'
                        }
                    });
                    if (response.ok) {
                        alert('강등이 완료되었습니다.');
                        window.location.reload();
                    }
                } catch (error) {
                    alert('처리 중 오류가 발생했습니다.');
                }
            }
        });
    });
});
728x90

'개인 프로젝트 > Toy & Side' 카테고리의 다른 글

[TOY] 작동 테스트  (0) 2025.01.09
[TOY] 개발 - 프론트엔드(Post)  (0) 2025.01.08
[TOY] 개발 - 수정  (0) 2025.01.04
[TOY] 개발 - 통합 테스트  (0) 2024.12.09
[TOY] 개발 - Adapters(Comment, Photo)  (0) 2024.12.05