diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml new file mode 100644 index 00000000..274b0516 --- /dev/null +++ b/.github/workflows/backend-build.yml @@ -0,0 +1,34 @@ +name: Backend Build + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository (저장소 체크아웃) + uses: actions/checkout@v3 + + - name: Set up JDK 17 (JDK 17 설정) + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: Grant execute permission for gradlew (Gradle 실행 권한 부여) + run: chmod +x backend/gradlew + + - name: 프로젝트 빌드 + run: | + cd backend + ./gradlew clean build -x test diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 00000000..97f7bd6c --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,7 @@ +.gradle +/build +/src/test +*.jar +*.log +.DS_Store + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..1a053e7b --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM eclipse-temurin:17-jdk as builder + +WORKDIR /app + +COPY . /app + +RUN chmod +x ./gradlew + +RUN ./gradlew clean bootJar + +FROM eclipse-temurin:17-jre +WORKDIR /app + +COPY --from=builder /app/build/libs/*.jar app.jar + +ENTRYPOINT ["java", "-jar", "/app/app.jar"] + diff --git a/backend/build.gradle b/backend/build.gradle index a19cbef5..8a70be0d 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -7,6 +7,10 @@ plugins { group = 'toonpick' version = '0.0.1-SNAPSHOT' +bootJar { + archiveFileName = "toonpick-${version}.jar" +} + java { sourceCompatibility = '17' } diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 00000000..98490227 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,85 @@ +services: + spring-app: + image: toonpick-service-app:0.0.1 + container_name: toonpick-service-app + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + volumes: + - ./logs:/app/logs + environment: + LOG_FILE: /app/logs/application.log + SPRING_DATASOURCE_DATA_URL: jdbc:mariadb://toonpick-db:3306/toonpick-database?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true + SPRING_DATASOURCE_DATA_USERNAME: root + SPRING_DATASOURCE_DATA_PASSWORD: 1234 + SPRING_DATASOURCE_META_URL: jdbc:mariadb://toonpick-db-meta:3306/meta?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true + SPRING_DATASOURCE_META_USERNAME: root + SPRING_DATASOURCE_META_PASSWORD: 1234 + restart: always + depends_on: + mariadb: + condition: service_healthy + mariadb-meta: + condition: service_healthy + redis: + condition: service_started + networks: + - backend + + mariadb: + image: mariadb:10.5 + container_name: toonpick-db + environment: + MYSQL_ROOT_PASSWORD: 1234 + MYSQL_DATABASE: toonpick-database + ports: + - "3306:3306" + volumes: + - mariadb-data:/var/lib/mysql + networks: + - backend + healthcheck: + test: ["CMD", "mysqladmin", "ping", "--host=localhost", "--user=root", "--password=1234"] + interval: 30s + retries: 5 + timeout: 10s + start_period: 30s + + mariadb-meta: + image: mariadb:10.5 + container_name: toonpick-db-meta + environment: + MYSQL_ROOT_PASSWORD: 1234 + MYSQL_DATABASE: meta + ports: + - "3307:3306" + volumes: + - mariadb-meta-data:/var/lib/mysql + networks: + - backend + healthcheck: + test: ["CMD", "mysqladmin", "ping", "--host=localhost", "--user=root", "--password=1234"] + interval: 30s + retries: 5 + timeout: 10s + start_period: 30s + + redis: + image: redis:6.0 + container_name: toonpick-redis + ports: + - "6380:6379" + networks: + - backend + +volumes: + mariadb-data: + driver: local + mariadb-meta-data: + driver: local + +networks: + backend: + driver: bridge diff --git a/backend/logs/info_2024-12-27.gz b/backend/logs/info_2024-12-27.gz deleted file mode 100644 index d89fc6c9..00000000 Binary files a/backend/logs/info_2024-12-27.gz and /dev/null differ diff --git a/backend/logs/info_2025-01-03.gz b/backend/logs/info_2025-01-03.gz deleted file mode 100644 index a2c2cd71..00000000 Binary files a/backend/logs/info_2025-01-03.gz and /dev/null differ diff --git a/backend/logs/info_2025-01-28.gz b/backend/logs/info_2025-01-28.gz new file mode 100644 index 00000000..7e181617 Binary files /dev/null and b/backend/logs/info_2025-01-28.gz differ diff --git a/backend/src/main/java/toonpick/app/config/DataDBConfig.java b/backend/src/main/java/toonpick/app/config/DataDBConfig.java index 79c66dde..0f122fa7 100644 --- a/backend/src/main/java/toonpick/app/config/DataDBConfig.java +++ b/backend/src/main/java/toonpick/app/config/DataDBConfig.java @@ -23,7 +23,7 @@ public class DataDBConfig { @Bean - @ConfigurationProperties(prefix = "spring.datasource-data") + @ConfigurationProperties(prefix = "spring.datasource.data") public DataSource dataDBSource() { return DataSourceBuilder.create().build(); } diff --git a/backend/src/main/java/toonpick/app/config/MetaDBConfig.java b/backend/src/main/java/toonpick/app/config/MetaDBConfig.java index 3caeac82..958a250f 100644 --- a/backend/src/main/java/toonpick/app/config/MetaDBConfig.java +++ b/backend/src/main/java/toonpick/app/config/MetaDBConfig.java @@ -16,7 +16,7 @@ public class MetaDBConfig { @Primary @Bean - @ConfigurationProperties(prefix = "spring.datasource-meta") + @ConfigurationProperties(prefix = "spring.datasource.meta") public DataSource metaDBSource(){ return DataSourceBuilder.create().build(); diff --git a/backend/src/main/java/toonpick/app/controller/HelloController.java b/backend/src/main/java/toonpick/app/controller/HelloController.java new file mode 100644 index 00000000..fb9e92b8 --- /dev/null +++ b/backend/src/main/java/toonpick/app/controller/HelloController.java @@ -0,0 +1,31 @@ +package toonpick.app.controller; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@Controller +public class HelloController { + + final private Logger logger = LoggerFactory.getLogger(HelloController.class); + + @GetMapping("/api/public/ping") + private ResponseEntity> testPing() { + Map response = new HashMap<>(); + + response.put("status", "success"); + response.put("message", "TOONPICK 서비스가 정상적으로 작동 중입니다."); + response.put("timestamp", LocalDateTime.now()); + + logger.info("Ping 요청이 들어왔습니다. 응답: {}", response); + + return ResponseEntity.status(HttpStatus.OK).body(response); + } +} diff --git a/backend/src/main/java/toonpick/app/exception/ErrorCode.java b/backend/src/main/java/toonpick/app/exception/ErrorCode.java index d9590f9e..9e738b6a 100644 --- a/backend/src/main/java/toonpick/app/exception/ErrorCode.java +++ b/backend/src/main/java/toonpick/app/exception/ErrorCode.java @@ -18,6 +18,10 @@ public enum ErrorCode { PERMISSION_DENIED(1002, "Permission denied"), // 2xxx : Security error + AUTHENTICATION_FAILED(2000, "Authentication failed"), + INVALID_CREDENTIALS(2001, "Invalid username or password"), + INVALID_JSON_FORMAT(2002, "Invalid JSON format in request"), + REQUEST_BODY_READ_ERROR(2003, "Failed to read authentication request body"), // 21xx : authorization error USER_ALREADY_REGISTERED(2103, "User is already registered"), diff --git a/backend/src/main/java/toonpick/app/exception/exception/CustomAuthenticationException.java b/backend/src/main/java/toonpick/app/exception/exception/CustomAuthenticationException.java new file mode 100644 index 00000000..0544a8e3 --- /dev/null +++ b/backend/src/main/java/toonpick/app/exception/exception/CustomAuthenticationException.java @@ -0,0 +1,19 @@ +package toonpick.app.exception.exception; + +import lombok.Getter; +import toonpick.app.exception.ErrorCode; + +@Getter +public class CustomAuthenticationException extends RuntimeException { + private final ErrorCode errorCode; + + public CustomAuthenticationException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public CustomAuthenticationException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + } +} diff --git a/backend/src/main/java/toonpick/app/exception/handler/AuthenticationExceptionHandler.java b/backend/src/main/java/toonpick/app/exception/handler/AuthenticationExceptionHandler.java index 2a5f74c1..1b02718e 100644 --- a/backend/src/main/java/toonpick/app/exception/handler/AuthenticationExceptionHandler.java +++ b/backend/src/main/java/toonpick/app/exception/handler/AuthenticationExceptionHandler.java @@ -9,6 +9,7 @@ import org.springframework.security.web.csrf.InvalidCsrfTokenException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import toonpick.app.exception.exception.CustomAuthenticationException; import toonpick.app.exception.exception.UserAlreadyRegisteredException; import java.nio.file.AccessDeniedException; @@ -21,43 +22,44 @@ public class AuthenticationExceptionHandler { @ExceptionHandler(UsernameNotFoundException.class) public ResponseEntity handleUsernameNotFoundException(UsernameNotFoundException ex) { LOGGER.error("Username not found: {}", ex.getMessage()); - return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Username not found: " + ex.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage()); } @ExceptionHandler(AccessDeniedException.class) public ResponseEntity handleAccessDeniedException(AccessDeniedException ex) { LOGGER.error("Access denied: {}", ex.getMessage()); - return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Access is denied. " + ex.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ex.getMessage()); + } + + @ExceptionHandler(CustomAuthenticationException.class) + public ResponseEntity handleAuthenticationException(CustomAuthenticationException ex) { + LOGGER.error("Authentication Failed: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ex.getMessage()); } @ExceptionHandler(AuthenticationCredentialsNotFoundException.class) public ResponseEntity handleAuthenticationCredentialsNotFoundException( AuthenticationCredentialsNotFoundException ex) { LOGGER.error("Authentication credentials not found: {}", ex.getMessage()); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication credentials are required."); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ex.getMessage()); } @ExceptionHandler(UserAlreadyRegisteredException.class) public ResponseEntity handleUsernameAlreadyExistsException(UserAlreadyRegisteredException ex) { LOGGER.error("Username already exists: {}", ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body("Username already exists: " + ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(ex.getMessage()); } @ExceptionHandler(InvalidCsrfTokenException.class) public ResponseEntity handleInvalidTokenException(InvalidCsrfTokenException ex) { LOGGER.error("Invalid Token: {}", ex.getMessage()); - return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Invalid CSRF token: " + ex.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ex.getMessage()); } @ExceptionHandler(NullPointerException.class) public ResponseEntity handleNullPointerException(NullPointerException ex) { LOGGER.error("Null pointer exception: {}", ex.getMessage(), ex); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid request: " + ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage()); } - @ExceptionHandler(Exception.class) - public ResponseEntity handleGeneralException(Exception ex) { - LOGGER.error("Unexpected error in authentication: {}", ex.getMessage(), ex); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("An unexpected error occurred: " + ex.getMessage()); - } } diff --git a/backend/src/main/java/toonpick/app/security/filter/LoginAuthenticationFilter.java b/backend/src/main/java/toonpick/app/security/filter/LoginAuthenticationFilter.java index 76b78ca3..208a9a45 100644 --- a/backend/src/main/java/toonpick/app/security/filter/LoginAuthenticationFilter.java +++ b/backend/src/main/java/toonpick/app/security/filter/LoginAuthenticationFilter.java @@ -1,5 +1,6 @@ package toonpick.app.security.filter; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; @@ -10,11 +11,14 @@ import org.slf4j.LoggerFactory; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.core.AuthenticationException; +import toonpick.app.exception.ErrorCode; +import toonpick.app.exception.exception.CustomAuthenticationException; import toonpick.app.security.dto.LoginRequest; import toonpick.app.security.handler.LoginFailureHandler; import toonpick.app.security.handler.LoginSuccessHandler; @@ -44,9 +48,18 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password); return authenticationManager.authenticate(authenticationToken); + } catch (BadCredentialsException e) { + logger.error(ErrorCode.INVALID_CREDENTIALS.getMessage()); + throw new CustomAuthenticationException(ErrorCode.INVALID_CREDENTIALS); + } catch (JsonProcessingException e) { + logger.error(ErrorCode.INVALID_JSON_FORMAT.getMessage()); + throw new CustomAuthenticationException(ErrorCode.INVALID_JSON_FORMAT); + } catch (IOException e) { + logger.error(ErrorCode.REQUEST_BODY_READ_ERROR.getMessage()); + throw new CustomAuthenticationException(ErrorCode.REQUEST_BODY_READ_ERROR); } catch (Exception e) { - logger.error("Failed to parse authentication request body", e); - throw new AuthenticationServiceException("Failed to parse authentication request body", e); + logger.error(ErrorCode.UNKNOWN_ERROR.getMessage(), e); + throw new CustomAuthenticationException(ErrorCode.UNKNOWN_ERROR, e); } } diff --git a/backend/src/main/java/toonpick/app/security/handler/OAuth2SuccessHandler.java b/backend/src/main/java/toonpick/app/security/handler/OAuth2SuccessHandler.java index 6d52464d..e1b371e4 100644 --- a/backend/src/main/java/toonpick/app/security/handler/OAuth2SuccessHandler.java +++ b/backend/src/main/java/toonpick/app/security/handler/OAuth2SuccessHandler.java @@ -32,11 +32,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo String role = authentication.getAuthorities().stream(). findFirst().map(GrantedAuthority::getAuthority).orElse(""); - String refreshToken = tokenService.issueAccessToken(username, role); - String accessToken = tokenService.issueRefreshToken(username, role); - - response.setHeader("Authorization", "Bearer " + accessToken); - response.addCookie(CookieUtils.createEmptyCookie(refreshToken)); + String refreshToken = tokenService.issueRefreshToken(username, role); + response.addCookie(CookieUtils.createRefreshCookie(refreshToken)); response.setStatus(HttpStatus.OK.value()); logger.info("USER LOGIN SUCCESS (username-{})", username); diff --git a/backend/src/main/java/toonpick/app/security/jwt/JwtTokenValidator.java b/backend/src/main/java/toonpick/app/security/jwt/JwtTokenValidator.java index 579ee124..892c4fc7 100644 --- a/backend/src/main/java/toonpick/app/security/jwt/JwtTokenValidator.java +++ b/backend/src/main/java/toonpick/app/security/jwt/JwtTokenValidator.java @@ -54,14 +54,12 @@ public void validateRefreshToken(String refreshToken) { if (refreshToken == null || refreshToken.isEmpty()) { throw new MissingJwtTokenException(ErrorCode.REFRESH_TOKEN_MISSING); } - + if (!"refresh".equals(jwtTokenProvider.getCategory(refreshToken))) { + throw new InvalidJwtTokenException(ErrorCode.REFRESH_TOKEN_INVALID, jwtTokenProvider.getCategory(refreshToken)); + } if (jwtTokenProvider.isExpired(refreshToken)) { throw new ExpiredJwtTokenException(ErrorCode.EXPIRED_REFRESH_TOKEN); } - - if (!"refresh".equals(jwtTokenProvider.getCategory(refreshToken))) { - throw new InvalidJwtTokenException(ErrorCode.REFRESH_TOKEN_INVALID); - } } // 유저 정보 추출 diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 5fe7b531..99843780 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -5,20 +5,20 @@ spring: config: import: application-API-KEY.yml - datasource-meta: - driver-class-name: org.mariadb.jdbc.Driver - jdbc-url: jdbc:mariadb://localhost:3306/meta?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true - username: root - password: 1234 - - datasource-data: - driver-class-name: org.mariadb.jdbc.Driver - jdbc-url: jdbc:mariadb://localhost:3306/toonpick-database?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true - username: root - password: 1234 + datasource: + data: + driver-class-name: org.mariadb.jdbc.Driver + jdbc-url: ${SPRING_DATASOURCE_DATA_URL} + username: ${SPRING_DATASOURCE_DATA_USERNAME} + password: ${SPRING_DATASOURCE_DATA_PASSWORD} + meta: + driver-class-name: org.mariadb.jdbc.Driver + jdbc-url: ${SPRING_DATASOURCE_META_URL} + username: ${SPRING_DATASOURCE_META_USERNAME} + password: ${SPRING_DATASOURCE_META_PASSWORD} redis: - host: localhost + host: redis port: 6380 timeout: 10000ms jedis: @@ -87,4 +87,7 @@ logging: springframework: DEBUG hibernate: SQL: DEBUG + file: + name: logs/application.log + max-size: 10MB diff --git a/frontend/public/images/profile/user.png b/frontend/public/images/profile/user.png new file mode 100644 index 00000000..bd07199e Binary files /dev/null and b/frontend/public/images/profile/user.png differ diff --git a/frontend/public/logo192.png b/frontend/public/logo192.png deleted file mode 100644 index fc44b0a3..00000000 Binary files a/frontend/public/logo192.png and /dev/null differ diff --git a/frontend/public/logo512.png b/frontend/public/logo512.png deleted file mode 100644 index a4e47a65..00000000 Binary files a/frontend/public/logo512.png and /dev/null differ diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt deleted file mode 100644 index e9e57dc4..00000000 --- a/frontend/public/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -# https://www.robotstxt.org/robotstxt.html -User-agent: * -Disallow: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7d1bebf8..2e63e60f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import SocialLoginCallbackPage from '@pages/auth/SocialLoginCallbackPage'; import NewWebtoonsPage from '@pages/NewWebtoonsPage'; import OngoingWebtoonsPage from '@pages/OngoingWebtoonsPage'; import CompletedWebtoonsPage from '@pages/CompletedWebtoonsPage'; +import ProfileEditPage from '@pages/ProfileEditPage'; import ErrorPage from '@pages/ErrorPage/ErrorPage'; const App: React.FC = () => { @@ -33,6 +34,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/AchievementItem/AchievementItem.module.css b/frontend/src/components/AchievementItem/AchievementItem.module.css new file mode 100644 index 00000000..1f38f746 --- /dev/null +++ b/frontend/src/components/AchievementItem/AchievementItem.module.css @@ -0,0 +1,38 @@ +.achievementItem { + display: flex; + align-items: center; + background-color: #ffffff; + border: 1px solid #ddd; + border-radius: 12px; + padding: 15px; + margin: 10px 0; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); + transition: transform 0.2s, box-shadow 0.2s; + cursor: pointer; +} + +.achievementItem:hover { + transform: scale(1.05); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2); +} + +.icon { + font-size: 28px; + margin-right: 15px; +} + +.details { + display: flex; + flex-direction: column; +} + +.title { + font-size: 18px; + font-weight: bold; + color: #333; +} + +.count { + font-size: 16px; + color: #666; +} \ No newline at end of file diff --git a/frontend/src/components/AchievementItem/AchievementItem.tsx b/frontend/src/components/AchievementItem/AchievementItem.tsx new file mode 100644 index 00000000..2e2a8477 --- /dev/null +++ b/frontend/src/components/AchievementItem/AchievementItem.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import styles from './AchievementItem.module.css'; +import { FaTrophy } from 'react-icons/fa'; + +interface AchievementItemProps { + title: string; + count: number; +} + +const AchievementItem: React.FC = ({ title, count }) => { + return ( +
+
+ +
+
+ {title} + {count} +
+
+ ); +}; + +export default AchievementItem; \ No newline at end of file diff --git a/frontend/src/components/AchievementItem/index.ts b/frontend/src/components/AchievementItem/index.ts new file mode 100644 index 00000000..a7b2b784 --- /dev/null +++ b/frontend/src/components/AchievementItem/index.ts @@ -0,0 +1 @@ +export { default } from './AchievementItem'; \ No newline at end of file diff --git a/frontend/src/components/BookMarkButton/index.tsx b/frontend/src/components/BookMarkButton/index.ts similarity index 100% rename from frontend/src/components/BookMarkButton/index.tsx rename to frontend/src/components/BookMarkButton/index.ts diff --git a/frontend/src/components/FavoriteButton/index.tsx b/frontend/src/components/FavoriteButton/index.ts similarity index 100% rename from frontend/src/components/FavoriteButton/index.tsx rename to frontend/src/components/FavoriteButton/index.ts diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 74358405..b1b91e90 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,65 +1,33 @@ -import React, { useContext, useEffect, useRef, useState } from 'react'; +import React, { useContext, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { AuthContext } from '@contexts/AuthContext'; import ProfileWidget from '@components/ProfileWidget'; +import Menu from './Menu'; +import Search from './Search'; import styles from './Header.module.css'; -import { FiSearch, FiBell, FiSun, FiMoon } from 'react-icons/fi'; const Header: React.FC = () => { const navigate = useNavigate(); - const { isLoggedIn, logout } = useContext(AuthContext); + const { isLoggedIn, logout, memberProfile } = useContext(AuthContext); const [isProfileWidgetOpen, setProfileWidgetOpen] = useState(false); - const [isSearchInputVisible, setSearchInputVisible] = useState(false); const [isDarkTheme, setIsDarkTheme] = useState(false); const profileButtonRef = useRef(null); const profileWidgetRef = useRef(null); - const searchInputRef = useRef(null); const handleLogout = async (): Promise => { logout(); navigate('/login'); }; - const toggleSearchInput = (event: React.MouseEvent): void => { - event.stopPropagation(); - setSearchInputVisible((prev) => !prev); - }; - const toggleTheme = (): void => { setIsDarkTheme((prev) => !prev); const theme = isDarkTheme ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', theme); - - const linkElement = document.createElement('link'); - linkElement.rel = 'stylesheet'; - linkElement.href = '/styles/theme.css'; - document.head.appendChild(linkElement); }; - useEffect(() => { - const handleClickOutside = (event: MouseEvent): void => { - const isProfileClick = profileButtonRef.current && profileButtonRef.current.contains(event.target as Node); - const isSearchClick = searchInputRef.current && searchInputRef.current.contains(event.target as Node); - const isProfileWidgetClick = profileWidgetRef.current && profileWidgetRef.current.contains(event.target as Node); - - if (!isProfileClick && !isProfileWidgetClick) { - setProfileWidgetOpen(false); - } - - if (!isSearchClick) { - setSearchInputVisible(false); - } - }; - - window.addEventListener('click', handleClickOutside); - return () => { - window.removeEventListener('click', handleClickOutside); - }; - }, []); - - const handleMouseEnterProfile = (): void => { - setProfileWidgetOpen(true); + const toggleProfileWidget = (): void => { + setProfileWidgetOpen((prev) => !prev); }; return ( @@ -67,63 +35,28 @@ const Header: React.FC = () => {

navigate('/')}>TOONPICK

- +
-
- {isSearchInputVisible && ( - ) => { - if (e.key === 'Enter') { - navigate(`/search?query=${e.currentTarget.value}`); - } - }} - /> - )} - - - - - -
+ -
+
{isLoggedIn ? ( <> void; +} + +const Menu: React.FC = ({ navigate }) => { + return ( + + ); +}; + +export default Menu; \ No newline at end of file diff --git a/frontend/src/components/Header/Search.tsx b/frontend/src/components/Header/Search.tsx new file mode 100644 index 00000000..531965f2 --- /dev/null +++ b/frontend/src/components/Header/Search.tsx @@ -0,0 +1,54 @@ +import React, { useState, useRef } from 'react'; +import { FiSearch, FiSun, FiMoon, FiBell } from 'react-icons/fi'; +import styles from './Header.module.css'; + +interface SearchProps { + navigate: (path: string) => void; + isDarkTheme: boolean; + toggleTheme: () => void; +} + +const Search: React.FC = ({ navigate, isDarkTheme, toggleTheme }) => { + const [isSearchInputVisible, setSearchInputVisible] = useState(false); + const searchInputRef = useRef(null); + + const toggleSearchInput = (event: React.MouseEvent): void => { + event.stopPropagation(); + setSearchInputVisible((prev) => !prev); + }; + + return ( +
+ {isSearchInputVisible && ( + ) => { + if (e.key === 'Enter') { + navigate(`/search?query=${e.currentTarget.value}`); + } + }} + /> + )} + + + + + +
+ ); +}; + +export default Search; \ No newline at end of file diff --git a/frontend/src/components/LevelDisplay/LevelDisplay.module.css b/frontend/src/components/LevelDisplay/LevelDisplay.module.css new file mode 100644 index 00000000..f6af7706 --- /dev/null +++ b/frontend/src/components/LevelDisplay/LevelDisplay.module.css @@ -0,0 +1,43 @@ +.levelContainer { + display: flex; + align-items: center; + margin-top: 10px; + width: 100%; + max-width: 300px; +} + +.progressBar { + flex-grow: 1; + height: 8px; + background-color: #e0e0e0; + border-radius: 4px; + overflow: hidden; + margin-right: 10px; +} + +.progress { + height: 100%; + background-color: #007bff; + transition: width 0.3s ease; +} + +.levelInfo { + font-size: 14px; + color: #333; +} + +.levelIcon { + font-size: 24px; + margin-right: 5px; +} + +.levelValue { + font-size: 16px; + color: #333; + display: flex; + align-items: center; +} + +.levelValue span { + margin-left: 5px; +} \ No newline at end of file diff --git a/frontend/src/components/LevelDisplay/LevelDisplay.tsx b/frontend/src/components/LevelDisplay/LevelDisplay.tsx new file mode 100644 index 00000000..13060df9 --- /dev/null +++ b/frontend/src/components/LevelDisplay/LevelDisplay.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import styles from './LevelDisplay.module.css'; + +interface LevelDisplayProps { + currentLevel: number; + currentPoints: number; + maxPoints: number; +} + +const LevelDisplay: React.FC = ({ currentLevel, currentPoints, maxPoints }) => { + const percentage = (currentPoints / maxPoints) * 100; // 경험치 비율 계산 + + return ( +
+
+
+
+
+ {currentLevel || 0} Lv +
+
+ ); +}; + +export default LevelDisplay; \ No newline at end of file diff --git a/frontend/src/components/LevelDisplay/index.ts b/frontend/src/components/LevelDisplay/index.ts new file mode 100644 index 00000000..84559d1f --- /dev/null +++ b/frontend/src/components/LevelDisplay/index.ts @@ -0,0 +1 @@ +export { default } from './LevelDisplay'; \ No newline at end of file diff --git a/frontend/src/components/LoginRequiredModal/index.tsx b/frontend/src/components/LoginRequiredModal/index.ts similarity index 100% rename from frontend/src/components/LoginRequiredModal/index.tsx rename to frontend/src/components/LoginRequiredModal/index.ts diff --git a/frontend/src/components/Pagination/index.tsx b/frontend/src/components/Pagination/index.ts similarity index 100% rename from frontend/src/components/Pagination/index.tsx rename to frontend/src/components/Pagination/index.ts diff --git a/frontend/src/components/PlatformIcon/index.tsx b/frontend/src/components/PlatformIcon/index.ts similarity index 100% rename from frontend/src/components/PlatformIcon/index.tsx rename to frontend/src/components/PlatformIcon/index.ts diff --git a/frontend/src/components/ProfileWidget/ProfileWidget.tsx b/frontend/src/components/ProfileWidget/ProfileWidget.tsx index c8a8887c..5323ddd6 100644 --- a/frontend/src/components/ProfileWidget/ProfileWidget.tsx +++ b/frontend/src/components/ProfileWidget/ProfileWidget.tsx @@ -1,10 +1,9 @@ import React, { useEffect, useCallback } from 'react'; +import { MemberProfile } from '@models/member'; import styles from './ProfileWidget.module.css'; interface ProfileWidgetProps { - userProfilePic: string; - userName: string; - userEmail: string; + memberProfile: MemberProfile | null; onNavigate: (path: string) => void; onLogout: () => void; isWidgetOpen: boolean; @@ -14,9 +13,7 @@ interface ProfileWidgetProps { } const ProfileWidget: React.FC = ({ - userProfilePic, - userName, - userEmail, + memberProfile, onNavigate, onLogout, isWidgetOpen, @@ -24,8 +21,13 @@ const ProfileWidget: React.FC = ({ profileButtonRef, profileWidgetRef, }) => { - const handleButtonClick = (action: () => void) => { + const handleButtonClick = (action: () => void, closeWidget: () => void) => (event: React.MouseEvent) => { + event.stopPropagation(); action(); + closeWidget(); + }; + + const closeProfileWidget = () => { setProfileWidgetOpen(false); }; @@ -34,7 +36,7 @@ const ProfileWidget: React.FC = ({ const isProfileWidgetClick = profileWidgetRef.current && profileWidgetRef.current.contains(event.target as Node); if (!isProfileClick && !isProfileWidgetClick) { - setProfileWidgetOpen(false); + closeProfileWidget(); } }, [profileButtonRef, profileWidgetRef, setProfileWidgetOpen]); @@ -45,6 +47,8 @@ const ProfileWidget: React.FC = ({ }; }, [handleClickOutside]); + const defaultProfilePicture = '/image/profile/user.png'; + return (
= ({ style={{ pointerEvents: isWidgetOpen ? 'auto' : 'none' }} ref={profileWidgetRef} > - User Profile + User Profile
-

{userName}

-

{userEmail}

+

{memberProfile?.nickname || '게스트 사용자'}

+ )} + {currentPage < totalPages - 1 && ( + + )}
- ) : ( -

웹툰이 없습니다.

)} -
- - -
); }; diff --git a/frontend/src/components/WebtoonList/index.tsx b/frontend/src/components/WebtoonList/index.ts similarity index 100% rename from frontend/src/components/WebtoonList/index.tsx rename to frontend/src/components/WebtoonList/index.ts diff --git a/frontend/src/components/WebtoonReviewCard/index.tsx b/frontend/src/components/WebtoonReviewCard/index.ts similarity index 100% rename from frontend/src/components/WebtoonReviewCard/index.tsx rename to frontend/src/components/WebtoonReviewCard/index.ts diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index f184b78e..1dd465c4 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,10 +1,13 @@ import React, { createContext, useState, useEffect, ReactNode } from 'react'; import AuthService from '@services/AuthService'; +import MemberService from '@services/MemberService'; +import { MemberProfile } from '@models/member'; interface AuthContextType { isLoggedIn: boolean; login: () => void; logout: () => void; + memberProfile: MemberProfile | null; } interface AuthProviderProps { @@ -15,31 +18,37 @@ export const AuthContext = createContext({ isLoggedIn: false, login: () => {}, logout: () => {}, + memberProfile: null, }); export const AuthProvider: React.FC = ({ children }) => { const [isLoggedIn, setIsLoggedIn] = useState(AuthService.isLoggedIn()); + const [memberProfile, setMemberProfile] = useState(null); useEffect(() => { setIsLoggedIn(AuthService.isLoggedIn()); - console.log("Initial isLoggedIn:", isLoggedIn); + if (isLoggedIn) { + const fetchMemberProfile = async () => { + const profile = await MemberService.getMemberProfile(); + setMemberProfile(profile.data || null); + }; + fetchMemberProfile(); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [isLoggedIn]); const login = (): void => { setIsLoggedIn(true); - console.log("login isLoggedIn:", isLoggedIn); }; const logout = (): void => { AuthService.logout(); - console.log("logout"); setIsLoggedIn(false); - console.log("logout isLoggedIn:", isLoggedIn); + setMemberProfile(null); }; return ( - + {children} ); diff --git a/frontend/src/models/member.ts b/frontend/src/models/member.ts index c5848050..c4be1773 100644 --- a/frontend/src/models/member.ts +++ b/frontend/src/models/member.ts @@ -2,21 +2,27 @@ export interface Member { username: string; nickname: string; + role: string; profilePicture: string; + email: string; + isAdultVerified: boolean; } export interface BasicMemberInfo { username: string; nickname: string; profilePicture: string; + role: string; } export interface MemberProfile { username: string; nickname: string; - role: string; profilePicture: string; - email: string; - isAdultVerified: boolean; + level: number; + points: number; + bookmarkedWebtoons: number; + watchedWebtoons: number; + ratedWebtoons: number; } \ No newline at end of file diff --git a/frontend/src/pages/CompletedWebtoonsPage/CompletedWebtoonsPage.module.css b/frontend/src/pages/CompletedWebtoonsPage/CompletedWebtoonsPage.module.css index ea83490d..4c32e247 100644 --- a/frontend/src/pages/CompletedWebtoonsPage/CompletedWebtoonsPage.module.css +++ b/frontend/src/pages/CompletedWebtoonsPage/CompletedWebtoonsPage.module.css @@ -1,5 +1,4 @@ .completedWebtoonsPage { - padding: 20px; width: 100%; max-width: 1200px; margin: 0 auto; diff --git a/frontend/src/pages/CompletedWebtoonsPage/index.tsx b/frontend/src/pages/CompletedWebtoonsPage/index.ts similarity index 100% rename from frontend/src/pages/CompletedWebtoonsPage/index.tsx rename to frontend/src/pages/CompletedWebtoonsPage/index.ts diff --git a/frontend/src/pages/ErrorPage/index.tsx b/frontend/src/pages/ErrorPage/index.ts similarity index 100% rename from frontend/src/pages/ErrorPage/index.tsx rename to frontend/src/pages/ErrorPage/index.ts diff --git a/frontend/src/pages/HomePage/index.tsx b/frontend/src/pages/HomePage/index.ts similarity index 100% rename from frontend/src/pages/HomePage/index.tsx rename to frontend/src/pages/HomePage/index.ts diff --git a/frontend/src/pages/MyProfilePage/MemberProfileSection/MemberProfileSection.module.css b/frontend/src/pages/MyProfilePage/MemberProfileSection/MemberProfileSection.module.css index 6986b6a9..61ac4552 100644 --- a/frontend/src/pages/MyProfilePage/MemberProfileSection/MemberProfileSection.module.css +++ b/frontend/src/pages/MyProfilePage/MemberProfileSection/MemberProfileSection.module.css @@ -1,10 +1,14 @@ .profile { margin: 20px 0; - padding: 20px; + padding: 30px; border-radius: 12px; background-color: #ffffff; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); transition: box-shadow 0.3s; + display: flex; + align-items: flex-start; + height: 270px; + width: 100%; } .profile:hover { @@ -13,25 +17,117 @@ .userInfo { display: flex; - align-items: center; + align-items: flex-start; + width: 100%; } -.userInfo img { - width: 80px; - height: 80px; +.profilePicture { + width: 100px; + height: 100px; border-radius: 50%; - margin-right: 20px; + margin-right: 30px; + margin-left: 10px; + border: 2px solid #ddd; +} + +.profileDetails { + display: flex; + flex-direction: column; + flex-grow: 1; } -.userInfo h3 { - font-size: 24px; +.header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.header h3 { + font-size: 28px; margin: 0; color: #333; } -.userInfo p { +.level, .points { font-size: 16px; color: #666; + margin: 5px 0; +} + +.stats { + display: flex; + justify-content: space-around; + margin-top: 10px; +} + +.statItem { + text-align: center; +} + +.statCount { + font-size: 18px; + font-weight: bold; + color: #333; +} + +.statLabel { + font-size: 14px; + color: #666; +} + +.achievements { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 15px; + align-items: center; +} + +.iconButton { + background: none; + border: none; + cursor: pointer; + margin-left: 10px; + font-size: 20px; + transition: color 0.3s; +} + +.iconButton:hover { + color: #007bff; +} + +.actions { + display: flex; + justify-content: flex-start; + margin-top: 15px; +} + +.followButton, .editProfileButton { + padding: 8px 16px; + border-radius: 8px; + font-size: 16px; + cursor: pointer; + margin-right: 10px; + border: none; + transition: background-color 0.3s; +} + +.followButton { + background-color: #007bff; + color: white; +} + +.followButton:hover { + background-color: #0056b3; +} + +.editProfileButton { + background-color: #f0f0f0; + color: #333; +} + +.editProfileButton:hover { + background-color: #e0e0e0; } @media (max-width: 768px) { diff --git a/frontend/src/pages/MyProfilePage/MemberProfileSection/MemberProfileSection.tsx b/frontend/src/pages/MyProfilePage/MemberProfileSection/MemberProfileSection.tsx index 49c5cd99..f277896a 100644 --- a/frontend/src/pages/MyProfilePage/MemberProfileSection/MemberProfileSection.tsx +++ b/frontend/src/pages/MyProfilePage/MemberProfileSection/MemberProfileSection.tsx @@ -1,22 +1,68 @@ -import React from 'react'; -import { MemberProfile } from '@models/member'; +import React, { useState } from 'react'; import styles from './MemberProfileSection.module.css'; +import { useNavigate } from 'react-router-dom'; +import { MemberProfile } from '@models/member'; +import { FaHeart, FaEdit, FaEllipsisH } from 'react-icons/fa'; +import AchievementItem from '@components/AchievementItem'; +import LevelDisplay from '@components/LevelDisplay'; interface MemberProfileSectionProps { memberProfile: MemberProfile | null; } const MemberProfileSection: React.FC = ({ memberProfile }) => { - - console.log(memberProfile); - - return ( + const navigate = useNavigate(); + const [isFollowing, setIsFollowing] = useState(false); + const [followerCount, setFollowerCount] = useState(120); // 더미 데이터 + const [followingCount, setFollowingCount] = useState(150); // 더미 데이터 + const [recommendationCount, setRecommendationCount] = useState(30); // 더미 데이터 + const [reviewCount, setReviewCount] = useState(45); // 더미 데이터 + const [averageRating, setAverageRating] = useState(4.5); // 더미 데이터 + + const toggleFollow = () => { + setIsFollowing((prev) => !prev); + setFollowerCount((prev) => (isFollowing ? prev - 1 : prev + 1)); + }; + + const handleEditProfile = () => { + // TODO: 프로필 수정 기능 추가 + navigate('/profile/edit'); + }; + + const handleMoreOptions = () => { + // todo: 더보기 기능 추가 + }; + + return (
{memberProfile && (
- 프로필 -
-

{memberProfile.nickname}

+ 프로필 +
+
+

{memberProfile.nickname}

+
+ + + +
+
+ +
+ + + +
)} diff --git a/frontend/src/pages/MyProfilePage/MemberProfileSection/index.ts b/frontend/src/pages/MyProfilePage/MemberProfileSection/index.ts index 90791067..87ed0f2a 100644 --- a/frontend/src/pages/MyProfilePage/MemberProfileSection/index.ts +++ b/frontend/src/pages/MyProfilePage/MemberProfileSection/index.ts @@ -1 +1 @@ -export { default } from './MemberProfileSection'; +export { default } from './MemberProfileSection'; \ No newline at end of file diff --git a/frontend/src/pages/MyProfilePage/MyProfilePage.tsx b/frontend/src/pages/MyProfilePage/MyProfilePage.tsx index 3e150702..4b154781 100644 --- a/frontend/src/pages/MyProfilePage/MyProfilePage.tsx +++ b/frontend/src/pages/MyProfilePage/MyProfilePage.tsx @@ -43,8 +43,6 @@ const MyProfilePage: React.FC = () => { MemberService.getFavorites(), ]); - console.log(profile.data); - setState({ memberProfile: profile.data || null, bookmarks: bookmarks.data || [], @@ -74,12 +72,20 @@ const MyProfilePage: React.FC = () => {

북마크

- +

좋아요

- +
) : ( @@ -90,4 +96,4 @@ const MyProfilePage: React.FC = () => { ); }; -export default MyProfilePage; \ No newline at end of file +export default MyProfilePage; diff --git a/frontend/src/pages/MyProfilePage/index.tsx b/frontend/src/pages/MyProfilePage/index.ts similarity index 100% rename from frontend/src/pages/MyProfilePage/index.tsx rename to frontend/src/pages/MyProfilePage/index.ts diff --git a/frontend/src/pages/NewWebtoonsPage/NewWebtoonsPage.module.css b/frontend/src/pages/NewWebtoonsPage/NewWebtoonsPage.module.css index ce0f4dae..e7777e13 100644 --- a/frontend/src/pages/NewWebtoonsPage/NewWebtoonsPage.module.css +++ b/frontend/src/pages/NewWebtoonsPage/NewWebtoonsPage.module.css @@ -1,5 +1,4 @@ .newWebtoonsPageState { - padding: 20px; width: 100%; max-width: 1200px; margin: 0 auto; diff --git a/frontend/src/pages/NewWebtoonsPage/index.tsx b/frontend/src/pages/NewWebtoonsPage/index.ts similarity index 100% rename from frontend/src/pages/NewWebtoonsPage/index.tsx rename to frontend/src/pages/NewWebtoonsPage/index.ts diff --git a/frontend/src/pages/OngoingWebtoonsPage/OngoingWebtoonsPage.module.css b/frontend/src/pages/OngoingWebtoonsPage/OngoingWebtoonsPage.module.css index ce0f4dae..ae9a1e86 100644 --- a/frontend/src/pages/OngoingWebtoonsPage/OngoingWebtoonsPage.module.css +++ b/frontend/src/pages/OngoingWebtoonsPage/OngoingWebtoonsPage.module.css @@ -1,5 +1,4 @@ -.newWebtoonsPageState { - padding: 20px; +.ongoingWebtoonsPage { width: 100%; max-width: 1200px; margin: 0 auto; diff --git a/frontend/src/pages/OngoingWebtoonsPage/index.tsx b/frontend/src/pages/OngoingWebtoonsPage/index.ts similarity index 100% rename from frontend/src/pages/OngoingWebtoonsPage/index.tsx rename to frontend/src/pages/OngoingWebtoonsPage/index.ts diff --git a/frontend/src/pages/ProfileEditPage/ProfileEditPage.module.css b/frontend/src/pages/ProfileEditPage/ProfileEditPage.module.css new file mode 100644 index 00000000..f0c425d7 --- /dev/null +++ b/frontend/src/pages/ProfileEditPage/ProfileEditPage.module.css @@ -0,0 +1,95 @@ +.container { + display: flex; + max-width: 1200px; + margin: 0 auto; + padding: 20px; + background-color: #f9f9f9; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); +} + +.sidebar { + flex: 1; + padding: 20px; + background-color: #ffffff; + border-radius: 12px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + margin-right: 20px; +} + +.sidebar h2 { + font-size: 20px; + margin-bottom: 15px; + color: #333; +} + +.sidebar ul { + list-style: none; + padding: 0; +} + +.sidebar li { + padding: 10px; + cursor: pointer; + transition: background-color 0.3s; +} + +.sidebar li:hover { + background-color: #e0e0e0; +} + +.active { + background-color: #007bff; + color: white; +} + +.content { + flex: 3; + padding: 20px; + background-color: #ffffff; + border-radius: 12px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +h2 { + font-size: 24px; + margin-bottom: 20px; + color: #333; +} + +.profileSection, .settingsSection { + margin-bottom: 40px; +} + +label { + display: block; + margin-bottom: 15px; + font-size: 16px; + color: #666; +} + +.input, .select { + width: 100%; + padding: 10px; + margin-top: 5px; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 16px; +} + +.saveButton { + display: block; + width: 100%; + padding: 12px; + background-color: #007bff; + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s; +} + +.saveButton:hover { + background-color: #0056b3; +} \ No newline at end of file diff --git a/frontend/src/pages/ProfileEditPage/ProfileEditPage.tsx b/frontend/src/pages/ProfileEditPage/ProfileEditPage.tsx new file mode 100644 index 00000000..a837cdee --- /dev/null +++ b/frontend/src/pages/ProfileEditPage/ProfileEditPage.tsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; +import styles from './ProfileEditPage.module.css'; +import ProfileSection from './ProfileSection'; +import SettingsSection from './SettingsSection'; + +const ProfileEditPage: React.FC = () => { + const [activeSection, setActiveSection] = useState('profile'); + + return ( +
+
+

설정 메뉴

+
    +
  • setActiveSection('profile')} + > + 프로필 수정 +
  • +
  • setActiveSection('settings')} + > + 개인 설정 +
  • +
+
+
+ {activeSection === 'profile' && } + {activeSection === 'settings' && } +
+
+ ); +}; + +export default ProfileEditPage; \ No newline at end of file diff --git a/frontend/src/pages/ProfileEditPage/ProfilePictureUpload.module.css b/frontend/src/pages/ProfileEditPage/ProfilePictureUpload.module.css new file mode 100644 index 00000000..e62d831e --- /dev/null +++ b/frontend/src/pages/ProfileEditPage/ProfilePictureUpload.module.css @@ -0,0 +1,53 @@ +.uploadContainer { + margin-bottom: 40px; +} + +.preview { + width: 100%; + height: 200px; + border: 1px dashed #ddd; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + background-color: #f9f9f9; +} + +.previewImage { + max-width: 100%; + max-height: 100%; + border-radius: 8px; +} + +.placeholder { + color: #aaa; +} + +.fileInputLabel { + display: block; + margin-bottom: 15px; + font-size: 16px; + color: #666; +} + +.fileInput { + display: none; /* 숨김 처리 */ +} + +.saveButton { + display: block; + width: 100%; + padding: 12px; + background-color: #007bff; + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s; +} + +.saveButton:hover { + background-color: #0056b3; +} \ No newline at end of file diff --git a/frontend/src/pages/ProfileEditPage/ProfilePictureUpload.tsx b/frontend/src/pages/ProfileEditPage/ProfilePictureUpload.tsx new file mode 100644 index 00000000..f2e5fbcd --- /dev/null +++ b/frontend/src/pages/ProfileEditPage/ProfilePictureUpload.tsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; +import styles from './ProfilePictureUpload.module.css'; + +const ProfilePictureUpload: React.FC<{ onSave: (file: File | null) => void }> = ({ onSave }) => { + const [selectedFile, setSelectedFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files ? event.target.files[0] : null; + setSelectedFile(file); + if (file) { + const reader = new FileReader(); + reader.onloadend = () => { + setPreviewUrl(reader.result as string); + }; + reader.readAsDataURL(file); + } else { + setPreviewUrl(null); + } + }; + + const handleSave = () => { + if (selectedFile) { + onSave(selectedFile); + } + }; + + return ( +
+

프로필 사진 등록/수정

+
+ {previewUrl ? ( + 미리보기 + ) : ( +
미리보기 없음
+ )} +
+ + +
+ ); +}; + +export default ProfilePictureUpload; \ No newline at end of file diff --git a/frontend/src/pages/ProfileEditPage/ProfileSection.module.css b/frontend/src/pages/ProfileEditPage/ProfileSection.module.css new file mode 100644 index 00000000..7133f117 --- /dev/null +++ b/frontend/src/pages/ProfileEditPage/ProfileSection.module.css @@ -0,0 +1,42 @@ +.profileSection { + margin-bottom: 40px; +} + +h2 { + font-size: 24px; + margin-bottom: 20px; + color: #333; +} + +label { + display: block; + margin-bottom: 15px; + font-size: 16px; + color: #666; +} + +.input { + width: 100%; + padding: 10px; + margin-top: 5px; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 16px; +} + +.saveButton { + display: block; + width: 100%; + padding: 12px; + background-color: #007bff; + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s; +} + +.saveButton:hover { + background-color: #0056b3; +} \ No newline at end of file diff --git a/frontend/src/pages/ProfileEditPage/ProfileSection.tsx b/frontend/src/pages/ProfileEditPage/ProfileSection.tsx new file mode 100644 index 00000000..701de607 --- /dev/null +++ b/frontend/src/pages/ProfileEditPage/ProfileSection.tsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; +import styles from './ProfileSection.module.css'; +import ProfilePictureUpload from './ProfilePictureUpload'; + +const ProfileSection: React.FC = () => { + const [nickname, setNickname] = useState('사용자 닉네임'); + const [email, setEmail] = useState('user@example.com'); + const [password, setPassword] = useState(''); + const [profilePicture, setProfilePicture] = useState(null); + + const handleSaveProfile = () => { + // TODO: 프로필 저장 로직 추가 + console.log('프로필 저장:', { nickname, email, password, profilePicture }); + }; + + return ( +
+

프로필 수정

+ + + + + +
+ ); +}; + +export default ProfileSection; \ No newline at end of file diff --git a/frontend/src/pages/ProfileEditPage/SettingsSection.module.css b/frontend/src/pages/ProfileEditPage/SettingsSection.module.css new file mode 100644 index 00000000..b6ddb712 --- /dev/null +++ b/frontend/src/pages/ProfileEditPage/SettingsSection.module.css @@ -0,0 +1,42 @@ +.settingsSection { + margin-bottom: 40px; +} + +h2 { + font-size: 24px; + margin-bottom: 20px; + color: #333; +} + +label { + display: block; + margin-bottom: 15px; + font-size: 16px; + color: #666; +} + +.select { + width: 100%; + padding: 10px; + margin-top: 5px; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 16px; +} + +.saveButton { + display: block; + width: 100%; + padding: 12px; + background-color: #007bff; + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s; +} + +.saveButton:hover { + background-color: #0056b3; +} \ No newline at end of file diff --git a/frontend/src/pages/ProfileEditPage/SettingsSection.tsx b/frontend/src/pages/ProfileEditPage/SettingsSection.tsx new file mode 100644 index 00000000..a0b143d6 --- /dev/null +++ b/frontend/src/pages/ProfileEditPage/SettingsSection.tsx @@ -0,0 +1,111 @@ +import React, { useState } from 'react'; +import styles from './SettingsSection.module.css'; + +const SettingsSection: React.FC = () => { + const [notificationsEnabled, setNotificationsEnabled] = useState(true); + const [profileVisibility, setProfileVisibility] = useState('public'); + const [adultVerification, setAdultVerification] = useState(false); + const [commentVisibility, setCommentVisibility] = useState('public'); + const [ratingVisibility, setRatingVisibility] = useState('public'); + const [webtoonPreferenceVisibility, setWebtoonPreferenceVisibility] = useState('public'); + const [recommendedWebtoonRange, setRecommendedWebtoonRange] = useState('all'); + const [hiddenUsers, setHiddenUsers] = useState([]); + const [blockedUsers, setBlockedUsers] = useState([]); + + const handleSaveSettings = () => { + // TODO: 설정 저장 로직 추가 + console.log('설정 저장:', { + notificationsEnabled, + profileVisibility, + adultVerification, + commentVisibility, + ratingVisibility, + webtoonPreferenceVisibility, + recommendedWebtoonRange, + hiddenUsers, + blockedUsers, + }); + }; + + return ( +
+

개인 설정

+ + + + + + + + +
+ ); +}; + +export default SettingsSection; \ No newline at end of file diff --git a/frontend/src/pages/ProfileEditPage/index.ts b/frontend/src/pages/ProfileEditPage/index.ts new file mode 100644 index 00000000..f8054ca3 --- /dev/null +++ b/frontend/src/pages/ProfileEditPage/index.ts @@ -0,0 +1 @@ +export { default } from './ProfileEditPage'; \ No newline at end of file diff --git a/frontend/src/pages/WebtoonDetailPage/MemberInteractionSection/index.tsx b/frontend/src/pages/WebtoonDetailPage/MemberInteractionSection/index.ts similarity index 100% rename from frontend/src/pages/WebtoonDetailPage/MemberInteractionSection/index.tsx rename to frontend/src/pages/WebtoonDetailPage/MemberInteractionSection/index.ts diff --git a/frontend/src/pages/WebtoonDetailPage/WebtoonDetailsSection/index.tsx b/frontend/src/pages/WebtoonDetailPage/WebtoonDetailsSection/index.ts similarity index 100% rename from frontend/src/pages/WebtoonDetailPage/WebtoonDetailsSection/index.tsx rename to frontend/src/pages/WebtoonDetailPage/WebtoonDetailsSection/index.ts diff --git a/frontend/src/pages/WebtoonDetailPage/WebtoonRatingSection/index.tsx b/frontend/src/pages/WebtoonDetailPage/WebtoonRatingSection/index.ts similarity index 100% rename from frontend/src/pages/WebtoonDetailPage/WebtoonRatingSection/index.tsx rename to frontend/src/pages/WebtoonDetailPage/WebtoonRatingSection/index.ts diff --git a/frontend/src/pages/WebtoonDetailPage/index.tsx b/frontend/src/pages/WebtoonDetailPage/index.ts similarity index 100% rename from frontend/src/pages/WebtoonDetailPage/index.tsx rename to frontend/src/pages/WebtoonDetailPage/index.ts diff --git a/frontend/src/pages/auth/SignInPage/SignInPage.tsx b/frontend/src/pages/auth/SignInPage/SignInPage.tsx index fe31e630..71a6fdc7 100644 --- a/frontend/src/pages/auth/SignInPage/SignInPage.tsx +++ b/frontend/src/pages/auth/SignInPage/SignInPage.tsx @@ -28,6 +28,12 @@ const SignInPage: React.FC = () => { setError(''); setIsLoading(true); + if (!formData.username || !formData.password) { + setError('아이디와 비밀번호를 입력해주세요.'); + setIsLoading(false); + return; + } + try { const response = await AuthService.login(formData.username, formData.password, login); if (response.success) { @@ -66,7 +72,6 @@ const SignInPage: React.FC = () => { placeholder="아이디" value={formData.username} onChange={handleChange} - required />
@@ -77,7 +82,6 @@ const SignInPage: React.FC = () => { placeholder="비밀번호" value={formData.password} onChange={handleChange} - required />
@@ -101,9 +105,9 @@ const SignInPage: React.FC = () => {
- handleSocialLogin('google')} /> - handleSocialLogin('kakao')} /> - handleSocialLogin('naver')} /> + handleSocialLogin('google')} type="button" /> + handleSocialLogin('kakao')} type="button" /> + handleSocialLogin('naver')} type="button" />
diff --git a/frontend/src/pages/auth/SignInPage/index.tsx b/frontend/src/pages/auth/SignInPage/index.ts similarity index 100% rename from frontend/src/pages/auth/SignInPage/index.tsx rename to frontend/src/pages/auth/SignInPage/index.ts diff --git a/frontend/src/pages/auth/SignUpPage/SignUpPage.tsx b/frontend/src/pages/auth/SignUpPage/SignUpPage.tsx index dd200df7..9423d868 100644 --- a/frontend/src/pages/auth/SignUpPage/SignUpPage.tsx +++ b/frontend/src/pages/auth/SignUpPage/SignUpPage.tsx @@ -64,6 +64,8 @@ const SignUpPage: React.FC = () => { if (response.success) { navigate('/login'); + }else{ + setError('회원가입에 실패했습니다. 다시 시도해주세요.' + response.message); } } catch (err) { setError('회원가입에 실패했습니다. 다시 시도해주세요.'); diff --git a/frontend/src/pages/auth/SignUpPage/index.tsx b/frontend/src/pages/auth/SignUpPage/index.ts similarity index 100% rename from frontend/src/pages/auth/SignUpPage/index.tsx rename to frontend/src/pages/auth/SignUpPage/index.ts diff --git a/frontend/src/pages/auth/SocialLoginCallbackPage/index.tsx b/frontend/src/pages/auth/SocialLoginCallbackPage/index.ts similarity index 100% rename from frontend/src/pages/auth/SocialLoginCallbackPage/index.tsx rename to frontend/src/pages/auth/SocialLoginCallbackPage/index.ts diff --git a/frontend/src/services/AuthService.ts b/frontend/src/services/AuthService.ts index ac5b6c11..b6d51f33 100644 --- a/frontend/src/services/AuthService.ts +++ b/frontend/src/services/AuthService.ts @@ -25,10 +25,7 @@ export const AuthService = { }, // 회원가입 - signup: async (username: string, password: string, confirmPassword: string): Promise => { - if (password !== confirmPassword) { - return { success: false, message: 'Passwords do not match.' }; - } + signup: async (username: string, email: string ,password: string): Promise => { try { const joinPayload = { username,