diff --git a/README.md b/README.md new file mode 100644 index 000000000..7d529c4aa --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# 1단계 - 회원관리 기능 + +## 요구 사항 +- 회원 정보를 관리하는 기능 구현 +- 자신의 정보만 수정 가능하도록 해야하며 로그인이 선행되어야 함 +- 토큰의 유효성 검사와 본인 여부를 판단하는 로직 추가 +- side case에 대한 예외처리 +- 인수 테스트와 단위 테스트 작성 +- API 문서를 작성하고 문서화를 위한 테스트 작성 +- 페이지 연동 + +## 기능 목록 +1. 회원 가입 + - [x] password / password 확인의 값이 같지 않다면 api 를 호출하지 않는다 + - [x] email, name, password 를 담은 request 를 보낸다 + - [x] 각 값에 대한 유효성을 서버에서 검사한다 + - [x] 중복된 email 이 입력되었는지 검사한다 + - [x] 정보가 유효하다면 회원을 생성한다 + - [x] 실행 결과를 alert 를 이용해서 출력한다 + +2. 로그인 + - [x] email / password 의 값을 담아서 request 를 보낸다 + - [x] 각 값에 대한 유효성을 검사한다 + - [x] 유효한 회원이라면 토큰을 발행해서 로그인 상태를 유지한다 + +3. 로그인 후 회원정보 조회 / 수정 / 삭제 + - 조회 + - [x] 로그인이 되어있지 않다면 로그인 화면으로 연결한다. + - [x] 나의 정보를 조회 할때, 토큰을 검사해야 한다. + - [x] 이메일과 이름을 표시한다. + + - 수정(jwt 있어야 가능하다) + - [x] email 은 수정할 수 없다 + - [x] name / password 만 수정 가능하다 + + - 탈퇴 + - [x] jwt 토큰을 복호화한 email 로 member 를 찾는다 + - [x] 해당 member 를 삭제하고 jwt 값을 비운다 + +4. 로그아웃 + - [x] 클라이언트의 localStorage 의 jwt 값 비우고 메인 화면으로 이동 + +5. 뷰 수정 + - [x] 로그인 되어있으면 로그인 -> 로그아웃 / 회원가입 노출 안함 + +# 2단계 - 즐겨찾기 기능 + + + +## 요구사항 + +- 즐겨찾기 기능을 추가(추가,삭제,조회) +- 자신의 정보만 수정 가능하도록 해야하며 **로그인이 선행**되어야 함 +- 토큰의 유효성 검사와 본인 여부를 판단하는 로직 추가(interceptor, argument resolver) +- side case에 대한 예외처리 필수 +- 인수 테스트와 단위 테스트 작성 +- API 문서를 작성하고 문서화를 위한 테스트 작성 +- 페이지 연동 + + + +## 기능 목록 + +### 1. 즐겨찾기 추가 + +- [x] 로그인이 선행된 상태에서 즐겨찾기를 추가할 수 있다 + - [x] 즐겨찾기 버튼을 누를 때 토큰을 검사한다 +- [x] 즐겨찾기 버튼을 누르면 해당 유저의 즐겨찾기 목록에 추가한다. + +### 2. 즐겨찾기 목록조회 / 제거 + +- [x] 즐겨찾기 페이지에 접속하면, 해당 유저의 즐겨찾기 목록을 출력한다. + - [x] 즐겨 찾기 페이지는 로그인후 접속 가능하다. +- [x] 즐겨찾기를 삭제하면, 해당 유저의 즐겨 찾기 목록에서 제거한다. \ No newline at end of file diff --git a/build.gradle b/build.gradle index 35b189610..28673f2e3 100644 --- a/build.gradle +++ b/build.gradle @@ -1,9 +1,9 @@ plugins { id 'org.springframework.boot' version '2.2.5.RELEASE' id 'io.spring.dependency-management' version '1.0.9.RELEASE' + id "org.asciidoctor.convert" version "1.5.9.2" id 'java' } - group = 'com.example' version = '0.0.1-SNAPSHOT' sourceCompatibility = '1.8' @@ -19,6 +19,9 @@ dependencies { implementation 'pl.allegro.tech.boot:handlebars-spring-boot-starter:0.3.0' implementation 'org.jgrapht:jgrapht-core:1.0.1' implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'javax.xml.bind:jaxb-api:2.3.1' + asciidoctor 'org.springframework.restdocs:spring-restdocs-asciidoctor:2.0.4.RELEASE' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc:2.0.4.RELEASE' testImplementation 'io.rest-assured:rest-assured:3.3.0' testImplementation('org.springframework.boot:spring-boot-starter-test') { exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' @@ -26,6 +29,22 @@ dependencies { runtimeOnly 'com.h2database:h2' } +ext { + snippetsDir = file('build/generated-snippets') +} + test { useJUnitPlatform() + outputs.dir snippetsDir +} +asciidoctor { + inputs.dir snippetsDir + dependsOn test +} + +bootJar { + dependsOn asciidoctor + from ("${asciidoctor.outputDir}/html5") { + into 'static/docs' + } } diff --git a/src/docs/asciidoc/api-guide.adoc b/src/docs/asciidoc/api-guide.adoc index 89b6e97f8..30802f586 100644 --- a/src/docs/asciidoc/api-guide.adoc +++ b/src/docs/asciidoc/api-guide.adoc @@ -9,7 +9,6 @@ endif::[] :sectlinks: :operation-http-request-title: Example Request :operation-http-response-title: Example Response - [[resources]] = Resources @@ -19,4 +18,37 @@ endif::[] [[resources-members-create]] === 회원 가입 -operation::members/create[snippets='http-request,http-response'] \ No newline at end of file +operation::members/create[snippets='http-request,http-response,request-fields'] + +[[resources-oauth-token-bearer]] +=== 로그인 + +operation::oauth/token/bearer[snippets='http-request,request-fields,http-response,response-fields'] + +[[resources-me-bearer-update]] +=== 회원 정보 수정 + +operation::me/bearer/update[snippets='http-request,request-headers,request-body,request-fields'] + +[[resources-me-bearer-delete]] +=== 회원 탈퇴 + +operation::me/bearer/delete[snippets='http-request,request-headers'] + +[[resources-favorites]] +== Favorite + +[[resources-favorites-create]] +=== 즐겨찾기 생성 + +operation::favorites/create[snippets='http-request,request-headers,request-fields,http-response,response-headers'] + +[[resources-favorites-show]] +=== 즐겨찾기 조회 + +operation::favorites/show[snippets='http-request,request-headers,http-response,response-fields'] + +[[resources-favorites-delete]] +=== 즐겨찾기 삭제 + +operation::favorites/delete[snippets='http-request,request-headers,request-fields'] \ No newline at end of file diff --git a/src/main/java/wooteco/subway/config/WebMvcConfig.java b/src/main/java/wooteco/subway/config/WebMvcConfig.java index 87217dc79..b6d63068a 100644 --- a/src/main/java/wooteco/subway/config/WebMvcConfig.java +++ b/src/main/java/wooteco/subway/config/WebMvcConfig.java @@ -1,37 +1,29 @@ package wooteco.subway.config; +import java.util.List; + import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + import wooteco.subway.web.member.LoginMemberMethodArgumentResolver; -import wooteco.subway.web.member.interceptor.BasicAuthInterceptor; import wooteco.subway.web.member.interceptor.BearerAuthInterceptor; -import wooteco.subway.web.member.interceptor.SessionInterceptor; - -import java.util.List; @Configuration public class WebMvcConfig implements WebMvcConfigurer { - private final BasicAuthInterceptor basicAuthInterceptor; - private final SessionInterceptor sessionInterceptor; private final BearerAuthInterceptor bearerAuthInterceptor; private final LoginMemberMethodArgumentResolver loginMemberArgumentResolver; - public WebMvcConfig(BasicAuthInterceptor basicAuthInterceptor, - SessionInterceptor sessionInterceptor, - BearerAuthInterceptor bearerAuthInterceptor, + public WebMvcConfig(BearerAuthInterceptor bearerAuthInterceptor, LoginMemberMethodArgumentResolver loginMemberArgumentResolver) { - this.basicAuthInterceptor = basicAuthInterceptor; - this.sessionInterceptor = sessionInterceptor; this.bearerAuthInterceptor = bearerAuthInterceptor; this.loginMemberArgumentResolver = loginMemberArgumentResolver; } @Override public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(basicAuthInterceptor).addPathPatterns("/me/basic"); - registry.addInterceptor(sessionInterceptor).addPathPatterns("/me/session"); registry.addInterceptor(bearerAuthInterceptor).addPathPatterns("/me/bearer"); + registry.addInterceptor(bearerAuthInterceptor).addPathPatterns("/favorites"); } @Override diff --git a/src/main/java/wooteco/subway/domain/favorite/Favorite.java b/src/main/java/wooteco/subway/domain/favorite/Favorite.java new file mode 100644 index 000000000..440baba43 --- /dev/null +++ b/src/main/java/wooteco/subway/domain/favorite/Favorite.java @@ -0,0 +1,51 @@ +package wooteco.subway.domain.favorite; + +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.PersistenceConstructor; +import wooteco.subway.service.favorite.dto.FavoriteRequest; + +public class Favorite { + @Id + private Long id; + private Long memberId; + private Long departureId; + private Long arrivalId; + + public Favorite(Long memberId, Long departureId, Long arrivalId) { + this(null, memberId, departureId, arrivalId); + } + + @PersistenceConstructor + public Favorite(Long id, Long memberId, Long departureId, Long arrivalId) { + this.id = id; + this.memberId = memberId; + this.departureId = departureId; + this.arrivalId = arrivalId; + } + + public static Favorite of(Long memberId, FavoriteRequest favoriteRequest) { + return new Favorite(memberId, favoriteRequest.getDepartureId(), favoriteRequest.getArrivalId()); + } + + public boolean isDuplicate(Favorite favorite) { + return this.memberId.equals(favorite.memberId) + && this.departureId.equals(favorite.departureId) + && this.arrivalId.equals(favorite.arrivalId); + } + + public Long getId() { + return id; + } + + public Long getMemberId() { + return memberId; + } + + public Long getDepartureId() { + return departureId; + } + + public Long getArrivalId() { + return arrivalId; + } +} diff --git a/src/main/java/wooteco/subway/domain/favorite/FavoriteRepository.java b/src/main/java/wooteco/subway/domain/favorite/FavoriteRepository.java new file mode 100644 index 000000000..d4d0f7984 --- /dev/null +++ b/src/main/java/wooteco/subway/domain/favorite/FavoriteRepository.java @@ -0,0 +1,19 @@ +package wooteco.subway.domain.favorite; + +import org.springframework.data.jdbc.repository.query.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface FavoriteRepository extends CrudRepository { + @Query("SELECT * FROM favorite WHERE member_id = :memberId") + List findAllByMemberId(@Param("memberId") Long memberId); + + @Query("SELECT * FROM favorite " + + "WHERE member_id = :memberId AND departure_id = :departureId AND arrival_id = :arrivalId") + Optional findByMemberIdAndDepartureIdAndArrivalId(@Param("memberId") Long memberId, + @Param("departureId") Long departureId, + @Param("arrivalId") Long arrivalId); +} diff --git a/src/main/java/wooteco/subway/domain/member/Member.java b/src/main/java/wooteco/subway/domain/member/Member.java index a9adbb264..d024ab427 100644 --- a/src/main/java/wooteco/subway/domain/member/Member.java +++ b/src/main/java/wooteco/subway/domain/member/Member.java @@ -3,6 +3,9 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.data.annotation.Id; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotEmpty; + public class Member { @Id private Long id; diff --git a/src/main/java/wooteco/subway/service/favorite/FavoriteService.java b/src/main/java/wooteco/subway/service/favorite/FavoriteService.java new file mode 100644 index 000000000..fd6d5a764 --- /dev/null +++ b/src/main/java/wooteco/subway/service/favorite/FavoriteService.java @@ -0,0 +1,66 @@ +package wooteco.subway.service.favorite; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import wooteco.subway.domain.favorite.Favorite; +import wooteco.subway.domain.favorite.FavoriteRepository; +import wooteco.subway.domain.member.MemberRepository; +import wooteco.subway.domain.station.StationRepository; +import wooteco.subway.service.favorite.dto.FavoriteRequest; +import wooteco.subway.service.favorite.dto.FavoriteResponse; +import wooteco.subway.service.favorite.exception.DuplicateFavoriteException; +import wooteco.subway.service.favorite.exception.NoExistFavoriteException; +import wooteco.subway.service.member.exception.InvalidMemberIdException; +import wooteco.subway.service.station.exception.InvalidStationNameException; + +import java.util.List; + +@Service +public class FavoriteService { + private final MemberRepository memberRepository; + private final StationRepository stationRepository; + private final FavoriteRepository favoriteRepository; + + public FavoriteService(MemberRepository memberRepository, StationRepository stationRepository, + FavoriteRepository favoriteRepository) { + this.memberRepository = memberRepository; + this.stationRepository = stationRepository; + this.favoriteRepository = favoriteRepository; + } + + @Transactional + public Long create(Long memberId, FavoriteRequest favoriteRequest) { + memberRepository.findById(memberId).orElseThrow(InvalidMemberIdException::new); + stationRepository.findById(favoriteRequest.getDepartureId()).orElseThrow(InvalidStationNameException::new); + stationRepository.findById(favoriteRequest.getArrivalId()).orElseThrow(InvalidStationNameException::new); + + Favorite favorite = Favorite.of(memberId, favoriteRequest); + + if (isDuplicateFavorite(memberId, favorite)) { + throw new DuplicateFavoriteException(); + } + + return favoriteRepository.save(favorite).getId(); + } + + private boolean isDuplicateFavorite(Long memberId, Favorite favorite) { + return favoriteRepository.findAllByMemberId(memberId) + .stream() + .anyMatch(f -> f.isDuplicate(favorite)); + } + + @Transactional + public void delete(Long memberId, FavoriteRequest favoriteRequest) { + Favorite favorite = favoriteRepository.findByMemberIdAndDepartureIdAndArrivalId(memberId, + favoriteRequest.getDepartureId(), favoriteRequest.getArrivalId()) + .orElseThrow(NoExistFavoriteException::new); + + favoriteRepository.delete(favorite); + } + + @Transactional(readOnly = true) + public List findAll(Long memberId) { + List favorites = favoriteRepository.findAllByMemberId(memberId); + return FavoriteResponse.listOf(favorites); + } +} diff --git a/src/main/java/wooteco/subway/service/favorite/dto/FavoriteRequest.java b/src/main/java/wooteco/subway/service/favorite/dto/FavoriteRequest.java new file mode 100644 index 000000000..fbe2c025a --- /dev/null +++ b/src/main/java/wooteco/subway/service/favorite/dto/FavoriteRequest.java @@ -0,0 +1,26 @@ +package wooteco.subway.service.favorite.dto; + +import javax.validation.constraints.NotNull; + +public class FavoriteRequest { + @NotNull + private Long departureId; + @NotNull + private Long arrivalId; + + private FavoriteRequest() { + } + + public FavoriteRequest(Long departureId, Long arrivalId) { + this.departureId = departureId; + this.arrivalId = arrivalId; + } + + public Long getDepartureId() { + return departureId; + } + + public Long getArrivalId() { + return arrivalId; + } +} diff --git a/src/main/java/wooteco/subway/service/favorite/dto/FavoriteResponse.java b/src/main/java/wooteco/subway/service/favorite/dto/FavoriteResponse.java new file mode 100644 index 000000000..35c5a0a31 --- /dev/null +++ b/src/main/java/wooteco/subway/service/favorite/dto/FavoriteResponse.java @@ -0,0 +1,38 @@ +package wooteco.subway.service.favorite.dto; + +import wooteco.subway.domain.favorite.Favorite; + +import java.util.List; + +import static java.util.stream.Collectors.toList; + +public class FavoriteResponse { + private Long departureId; + private Long arrivalId; + + private FavoriteResponse() { + } + + public FavoriteResponse(Long departureId, Long arrivalId) { + this.departureId = departureId; + this.arrivalId = arrivalId; + } + + public static FavoriteResponse of(Favorite favorite) { + return new FavoriteResponse(favorite.getDepartureId(), favorite.getArrivalId()); + } + + public static List listOf(List favorites) { + return favorites.stream() + .map(FavoriteResponse::of) + .collect(toList()); + } + + public Long getDepartureId() { + return departureId; + } + + public Long getArrivalId() { + return arrivalId; + } +} diff --git a/src/main/java/wooteco/subway/service/favorite/exception/DuplicateFavoriteException.java b/src/main/java/wooteco/subway/service/favorite/exception/DuplicateFavoriteException.java new file mode 100644 index 000000000..e9971115b --- /dev/null +++ b/src/main/java/wooteco/subway/service/favorite/exception/DuplicateFavoriteException.java @@ -0,0 +1,8 @@ +package wooteco.subway.service.favorite.exception; + +public class DuplicateFavoriteException extends RuntimeException { + public DuplicateFavoriteException() { + super("이미 존재하는 즐겨찾기 입니다."); + } +} + diff --git a/src/main/java/wooteco/subway/service/favorite/exception/NoExistFavoriteException.java b/src/main/java/wooteco/subway/service/favorite/exception/NoExistFavoriteException.java new file mode 100644 index 000000000..776e385b4 --- /dev/null +++ b/src/main/java/wooteco/subway/service/favorite/exception/NoExistFavoriteException.java @@ -0,0 +1,7 @@ +package wooteco.subway.service.favorite.exception; + +public class NoExistFavoriteException extends RuntimeException { + public NoExistFavoriteException() { + super("존재하지 않는 즐겨찾기 입니다."); + } +} diff --git a/src/main/java/wooteco/subway/service/member/MemberService.java b/src/main/java/wooteco/subway/service/member/MemberService.java index 12f3c2a12..bf9e1f506 100644 --- a/src/main/java/wooteco/subway/service/member/MemberService.java +++ b/src/main/java/wooteco/subway/service/member/MemberService.java @@ -1,38 +1,56 @@ package wooteco.subway.service.member; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import wooteco.subway.domain.member.Member; import wooteco.subway.domain.member.MemberRepository; import wooteco.subway.infra.JwtTokenProvider; import wooteco.subway.service.member.dto.LoginRequest; +import wooteco.subway.service.member.dto.MemberRequest; +import wooteco.subway.service.member.dto.MemberResponse; import wooteco.subway.service.member.dto.UpdateMemberRequest; +import wooteco.subway.service.member.exception.DuplicateEmailException; +import wooteco.subway.service.member.exception.InvalidMemberEmailException; +import wooteco.subway.service.member.exception.InvalidMemberIdException; @Service public class MemberService { - private MemberRepository memberRepository; - private JwtTokenProvider jwtTokenProvider; + private final MemberRepository memberRepository; + private final JwtTokenProvider jwtTokenProvider; public MemberService(MemberRepository memberRepository, JwtTokenProvider jwtTokenProvider) { this.memberRepository = memberRepository; this.jwtTokenProvider = jwtTokenProvider; } - public Member createMember(Member member) { - return memberRepository.save(member); + @Transactional + public MemberResponse createMember(MemberRequest memberRequest) { + if (memberRepository.findByEmail(memberRequest.getEmail()).isPresent()) { + throw new DuplicateEmailException(); + } + + Member member = memberRequest.toMember(); + + Member savedMember = memberRepository.save(member); + return MemberResponse.of(savedMember); } + @Transactional public void updateMember(Long id, UpdateMemberRequest param) { - Member member = memberRepository.findById(id).orElseThrow(RuntimeException::new); + Member member = memberRepository.findById(id).orElseThrow(InvalidMemberIdException::new); member.update(param.getName(), param.getPassword()); memberRepository.save(member); } + @Transactional public void deleteMember(Long id) { memberRepository.deleteById(id); } + @Transactional public String createToken(LoginRequest param) { - Member member = memberRepository.findByEmail(param.getEmail()).orElseThrow(RuntimeException::new); + Member member = memberRepository.findByEmail(param.getEmail()) + .orElseThrow(InvalidMemberEmailException::new); if (!member.checkPassword(param.getPassword())) { throw new RuntimeException("잘못된 패스워드"); } @@ -40,12 +58,10 @@ public String createToken(LoginRequest param) { return jwtTokenProvider.createToken(param.getEmail()); } - public Member findMemberByEmail(String email) { - return memberRepository.findByEmail(email).orElseThrow(RuntimeException::new); - } - - public boolean loginWithForm(String email, String password) { - Member member = findMemberByEmail(email); - return member.checkPassword(password); + @Transactional(readOnly = true) + public MemberResponse findMemberByEmail(String email) { + Member member = memberRepository.findByEmail(email) + .orElseThrow(InvalidMemberEmailException::new); + return MemberResponse.of(member); } } diff --git a/src/main/java/wooteco/subway/service/member/dto/ErrorResponse.java b/src/main/java/wooteco/subway/service/member/dto/ErrorResponse.java new file mode 100644 index 000000000..222c587fc --- /dev/null +++ b/src/main/java/wooteco/subway/service/member/dto/ErrorResponse.java @@ -0,0 +1,16 @@ +package wooteco.subway.service.member.dto; + +public class ErrorResponse { + private String errorMessage; + + private ErrorResponse() { + } + + public ErrorResponse(String errorMessage) { + this.errorMessage = errorMessage; + } + + public String getErrorMessage() { + return errorMessage; + } +} diff --git a/src/main/java/wooteco/subway/service/member/dto/LoginRequest.java b/src/main/java/wooteco/subway/service/member/dto/LoginRequest.java index ccfedd688..c01727f5f 100644 --- a/src/main/java/wooteco/subway/service/member/dto/LoginRequest.java +++ b/src/main/java/wooteco/subway/service/member/dto/LoginRequest.java @@ -1,10 +1,15 @@ package wooteco.subway.service.member.dto; +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; + public class LoginRequest { + @Email private String email; + @NotBlank private String password; - public LoginRequest() { + private LoginRequest() { } public LoginRequest(String email, String password) { diff --git a/src/main/java/wooteco/subway/service/member/dto/MemberRequest.java b/src/main/java/wooteco/subway/service/member/dto/MemberRequest.java index 05aed3875..b31f8367f 100644 --- a/src/main/java/wooteco/subway/service/member/dto/MemberRequest.java +++ b/src/main/java/wooteco/subway/service/member/dto/MemberRequest.java @@ -2,11 +2,26 @@ import wooteco.subway.domain.member.Member; +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; + public class MemberRequest { + @Email private String email; + @NotBlank private String name; + @NotBlank private String password; + private MemberRequest() { + } + + public MemberRequest(String email, String name, String password) { + this.email = email; + this.name = name; + this.password = password; + } + public String getEmail() { return email; } diff --git a/src/main/java/wooteco/subway/service/member/dto/MemberResponse.java b/src/main/java/wooteco/subway/service/member/dto/MemberResponse.java index ffad82ee2..1f6a520e6 100644 --- a/src/main/java/wooteco/subway/service/member/dto/MemberResponse.java +++ b/src/main/java/wooteco/subway/service/member/dto/MemberResponse.java @@ -7,6 +7,9 @@ public class MemberResponse { private String email; private String name; + private MemberResponse() { + } + public MemberResponse(Long id, String email, String name) { this.id = id; this.email = email; diff --git a/src/main/java/wooteco/subway/service/member/dto/UpdateMemberRequest.java b/src/main/java/wooteco/subway/service/member/dto/UpdateMemberRequest.java index d847ccf2b..0bb01f3c1 100644 --- a/src/main/java/wooteco/subway/service/member/dto/UpdateMemberRequest.java +++ b/src/main/java/wooteco/subway/service/member/dto/UpdateMemberRequest.java @@ -1,10 +1,14 @@ package wooteco.subway.service.member.dto; +import javax.validation.constraints.NotBlank; + public class UpdateMemberRequest { + @NotBlank private String name; + @NotBlank private String password; - public UpdateMemberRequest() { + private UpdateMemberRequest() { } public UpdateMemberRequest(String name, String password) { diff --git a/src/main/java/wooteco/subway/service/member/exception/DuplicateEmailException.java b/src/main/java/wooteco/subway/service/member/exception/DuplicateEmailException.java new file mode 100644 index 000000000..19f3a8aa2 --- /dev/null +++ b/src/main/java/wooteco/subway/service/member/exception/DuplicateEmailException.java @@ -0,0 +1,7 @@ +package wooteco.subway.service.member.exception; + +public class DuplicateEmailException extends RuntimeException { + public DuplicateEmailException() { + super("중복된 이메일을 입력하였습니다."); + } +} diff --git a/src/main/java/wooteco/subway/service/member/exception/InvalidMemberEmailException.java b/src/main/java/wooteco/subway/service/member/exception/InvalidMemberEmailException.java new file mode 100644 index 000000000..498542096 --- /dev/null +++ b/src/main/java/wooteco/subway/service/member/exception/InvalidMemberEmailException.java @@ -0,0 +1,7 @@ +package wooteco.subway.service.member.exception; + +public class InvalidMemberEmailException extends RuntimeException { + public InvalidMemberEmailException() { + super("회원 정보가 일치하지 않습니다."); + } +} diff --git a/src/main/java/wooteco/subway/service/member/exception/InvalidMemberIdException.java b/src/main/java/wooteco/subway/service/member/exception/InvalidMemberIdException.java new file mode 100644 index 000000000..eec1611c5 --- /dev/null +++ b/src/main/java/wooteco/subway/service/member/exception/InvalidMemberIdException.java @@ -0,0 +1,7 @@ +package wooteco.subway.service.member.exception; + +public class InvalidMemberIdException extends RuntimeException { + public InvalidMemberIdException() { + super("아이디에 일치하는 회원이 없습니다."); + } +} diff --git a/src/main/java/wooteco/subway/service/station/dto/StationResponse.java b/src/main/java/wooteco/subway/service/station/dto/StationResponse.java index 18011a71a..b15085efe 100644 --- a/src/main/java/wooteco/subway/service/station/dto/StationResponse.java +++ b/src/main/java/wooteco/subway/service/station/dto/StationResponse.java @@ -11,6 +11,9 @@ public class StationResponse { private String name; private LocalDateTime createdAt; + public StationResponse() { + } + public static StationResponse of(Station station) { return new StationResponse(station.getId(), station.getName(), station.getCreatedAt()); } @@ -21,9 +24,6 @@ public static List listOf(List stations) { .collect(Collectors.toList()); } - public StationResponse() { - } - public StationResponse(Long id, String name, LocalDateTime createdAt) { this.id = id; this.name = name; diff --git a/src/main/java/wooteco/subway/service/station/exception/InvalidStationNameException.java b/src/main/java/wooteco/subway/service/station/exception/InvalidStationNameException.java new file mode 100644 index 000000000..bcfb02aff --- /dev/null +++ b/src/main/java/wooteco/subway/service/station/exception/InvalidStationNameException.java @@ -0,0 +1,7 @@ +package wooteco.subway.service.station.exception; + +public class InvalidStationNameException extends RuntimeException { + public InvalidStationNameException() { + super("이름과 일치하는 역이 존재하지 않습니다."); + } +} diff --git a/src/main/java/wooteco/subway/web/ExceptionController.java b/src/main/java/wooteco/subway/web/ExceptionController.java new file mode 100644 index 000000000..53979e4db --- /dev/null +++ b/src/main/java/wooteco/subway/web/ExceptionController.java @@ -0,0 +1,28 @@ +package wooteco.subway.web; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import wooteco.subway.service.favorite.exception.DuplicateFavoriteException; +import wooteco.subway.service.member.dto.ErrorResponse; +import wooteco.subway.service.member.exception.DuplicateEmailException; +import wooteco.subway.service.member.exception.InvalidMemberEmailException; +import wooteco.subway.service.member.exception.InvalidMemberIdException; + +@ControllerAdvice +public class ExceptionController { + @ExceptionHandler(value = {DuplicateEmailException.class, InvalidMemberIdException.class, + InvalidMemberEmailException.class, DuplicateFavoriteException.class, MethodArgumentNotValidException.class}) + public ResponseEntity getDuplicateKeyException(RuntimeException exception) { + return ResponseEntity.badRequest().body(new ErrorResponse(exception.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity getInternalServerException(Exception exception) { + System.out.println(exception.getMessage()); + exception.getStackTrace(); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/main/java/wooteco/subway/web/FavoriteController.java b/src/main/java/wooteco/subway/web/FavoriteController.java new file mode 100644 index 000000000..19588a664 --- /dev/null +++ b/src/main/java/wooteco/subway/web/FavoriteController.java @@ -0,0 +1,44 @@ +package wooteco.subway.web; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import wooteco.subway.service.favorite.FavoriteService; +import wooteco.subway.service.favorite.dto.FavoriteRequest; +import wooteco.subway.service.favorite.dto.FavoriteResponse; +import wooteco.subway.service.member.dto.MemberResponse; +import wooteco.subway.web.member.LoginMember; + +import javax.validation.Valid; +import java.net.URI; +import java.util.List; + +@RestController +@RequestMapping("/favorites") +public class FavoriteController { + private final FavoriteService favoriteService; + + public FavoriteController(FavoriteService favoriteService) { + this.favoriteService = favoriteService; + } + + @PostMapping + public ResponseEntity createFavorite(@LoginMember MemberResponse memberResponse, + @RequestBody @Valid FavoriteRequest favoriteRequest) { + Long favoriteId = favoriteService.create(memberResponse.getId(), favoriteRequest); + return ResponseEntity.created(URI.create("/favorites/" + favoriteId)).build(); + } + + @GetMapping + public ResponseEntity> showFavorites( + @LoginMember MemberResponse memberResponse) { + List responses = favoriteService.findAll(memberResponse.getId()); + return ResponseEntity.ok(responses); + } + + @DeleteMapping + public ResponseEntity deleteFavorite(@LoginMember MemberResponse memberResponse, + @RequestBody @Valid FavoriteRequest favoriteRequest) { + favoriteService.delete(memberResponse.getId(), favoriteRequest); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/wooteco/subway/web/PageController.java b/src/main/java/wooteco/subway/web/PageController.java index c60345782..de4540590 100644 --- a/src/main/java/wooteco/subway/web/PageController.java +++ b/src/main/java/wooteco/subway/web/PageController.java @@ -9,8 +9,8 @@ @Controller public class PageController { - private LineService lineService; - private StationService stationService; + private final LineService lineService; + private final StationService stationService; public PageController(LineService lineService, StationService stationService) { this.lineService = lineService; @@ -19,7 +19,7 @@ public PageController(LineService lineService, StationService stationService) { @GetMapping(value = "/", produces = MediaType.TEXT_HTML_VALUE) public String index() { - return "admin/index"; + return "service/index"; } @GetMapping(value = "/stations", produces = MediaType.TEXT_HTML_VALUE) @@ -59,8 +59,18 @@ public String loginPage() { return "service/login"; } - @GetMapping(value = "/favorites", produces = MediaType.TEXT_HTML_VALUE) + @GetMapping(value = "/favorite-page", produces = MediaType.TEXT_HTML_VALUE) public String favoritesPage() { return "service/favorite"; } + + @GetMapping(value = "/mypage", produces = MediaType.TEXT_HTML_VALUE) + public String myPage() { + return "service/mypage"; + } + + @GetMapping(value = "/mypage-edit", produces = MediaType.TEXT_HTML_VALUE) + public String myPageEdit() { + return "service/mypage-edit"; + } } diff --git a/src/main/java/wooteco/subway/web/member/LoginMemberController.java b/src/main/java/wooteco/subway/web/member/LoginMemberController.java index 9eb4f13a2..f86f0b7d0 100644 --- a/src/main/java/wooteco/subway/web/member/LoginMemberController.java +++ b/src/main/java/wooteco/subway/web/member/LoginMemberController.java @@ -2,44 +2,40 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import wooteco.subway.domain.member.Member; import wooteco.subway.service.member.MemberService; -import wooteco.subway.service.member.dto.LoginRequest; -import wooteco.subway.service.member.dto.MemberResponse; -import wooteco.subway.service.member.dto.TokenResponse; +import wooteco.subway.service.member.dto.*; -import javax.servlet.http.HttpSession; -import java.util.Map; +import javax.validation.Valid; @RestController public class LoginMemberController { - private MemberService memberService; + private final MemberService memberService; public LoginMemberController(MemberService memberService) { this.memberService = memberService; } @PostMapping("/oauth/token") - public ResponseEntity login(@RequestBody LoginRequest param) { + public ResponseEntity login(@RequestBody @Valid LoginRequest param) { String token = memberService.createToken(param); return ResponseEntity.ok().body(new TokenResponse(token, "bearer")); } - @PostMapping("/login") - public ResponseEntity login(@RequestParam Map paramMap, HttpSession session) { - String email = paramMap.get("email"); - String password = paramMap.get("password"); - if (!memberService.loginWithForm(email, password)) { - throw new InvalidAuthenticationException("올바르지 않은 이메일과 비밀번호 입력"); - } - - session.setAttribute("loginMemberEmail", email); + @GetMapping("/me/bearer") + public ResponseEntity getMemberOfMineBasic(@LoginMember MemberResponse memberResponse) { + return ResponseEntity.ok().body(memberResponse); + } + @PutMapping("/me/bearer") + public ResponseEntity updateMember(@LoginMember MemberResponse memberResponse, + @RequestBody @Valid UpdateMemberRequest updateMemberRequest) { + memberService.updateMember(memberResponse.getId(), updateMemberRequest); return ResponseEntity.ok().build(); } - @GetMapping({"/me/basic", "/me/session", "/me/bearer"}) - public ResponseEntity getMemberOfMineBasic(@LoginMember Member member) { - return ResponseEntity.ok().body(MemberResponse.of(member)); + @DeleteMapping("/me/bearer") + public ResponseEntity deleteMember(@LoginMember MemberResponse memberResponse) { + memberService.deleteMember(memberResponse.getId()); + return ResponseEntity.ok().build(); } } diff --git a/src/main/java/wooteco/subway/web/member/LoginMemberMethodArgumentResolver.java b/src/main/java/wooteco/subway/web/member/LoginMemberMethodArgumentResolver.java index 592faba51..90b3433f3 100644 --- a/src/main/java/wooteco/subway/web/member/LoginMemberMethodArgumentResolver.java +++ b/src/main/java/wooteco/subway/web/member/LoginMemberMethodArgumentResolver.java @@ -1,5 +1,7 @@ package wooteco.subway.web.member; +import static org.springframework.web.context.request.RequestAttributes.*; + import org.apache.commons.lang3.StringUtils; import org.springframework.core.MethodParameter; import org.springframework.stereotype.Component; @@ -7,10 +9,10 @@ import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; + import wooteco.subway.domain.member.Member; import wooteco.subway.service.member.MemberService; - -import static org.springframework.web.context.request.RequestAttributes.SCOPE_REQUEST; +import wooteco.subway.service.member.dto.MemberResponse; @Component public class LoginMemberMethodArgumentResolver implements HandlerMethodArgumentResolver { @@ -30,7 +32,7 @@ public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer m NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { String email = (String) webRequest.getAttribute("loginMemberEmail", SCOPE_REQUEST); if (StringUtils.isBlank(email)) { - return new Member(); + return MemberResponse.of(new Member()); } try { return memberService.findMemberByEmail(email); diff --git a/src/main/java/wooteco/subway/web/member/MemberController.java b/src/main/java/wooteco/subway/web/member/MemberController.java index 8a3046948..b30499c39 100644 --- a/src/main/java/wooteco/subway/web/member/MemberController.java +++ b/src/main/java/wooteco/subway/web/member/MemberController.java @@ -2,38 +2,39 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import wooteco.subway.domain.member.Member; import wooteco.subway.service.member.MemberService; import wooteco.subway.service.member.dto.MemberRequest; import wooteco.subway.service.member.dto.MemberResponse; import wooteco.subway.service.member.dto.UpdateMemberRequest; +import javax.validation.Valid; import java.net.URI; @RestController public class MemberController { - private MemberService memberService; + private final MemberService memberService; public MemberController(MemberService memberService) { this.memberService = memberService; } @PostMapping("/members") - public ResponseEntity createMember(@RequestBody MemberRequest view) { - Member member = memberService.createMember(view.toMember()); + public ResponseEntity createMember(@RequestBody @Valid MemberRequest memberRequest) { + MemberResponse memberResponse = memberService.createMember(memberRequest); return ResponseEntity - .created(URI.create("/members/" + member.getId())) + .created(URI.create("/members/" + memberResponse.getId())) .build(); } @GetMapping("/members") public ResponseEntity getMemberByEmail(@RequestParam String email) { - Member member = memberService.findMemberByEmail(email); - return ResponseEntity.ok().body(MemberResponse.of(member)); + MemberResponse memberResponse = memberService.findMemberByEmail(email); + return ResponseEntity.ok().body(memberResponse); } @PutMapping("/members/{id}") - public ResponseEntity updateMember(@PathVariable Long id, @RequestBody UpdateMemberRequest param) { + public ResponseEntity updateMember(@PathVariable Long id, + @RequestBody @Valid UpdateMemberRequest param) { memberService.updateMember(id, param); return ResponseEntity.ok().build(); } diff --git a/src/main/java/wooteco/subway/web/member/interceptor/BasicAuthInterceptor.java b/src/main/java/wooteco/subway/web/member/interceptor/BasicAuthInterceptor.java deleted file mode 100644 index a5f3a481b..000000000 --- a/src/main/java/wooteco/subway/web/member/interceptor/BasicAuthInterceptor.java +++ /dev/null @@ -1,40 +0,0 @@ -package wooteco.subway.web.member.interceptor; - -import org.springframework.stereotype.Component; -import org.springframework.web.servlet.HandlerInterceptor; -import wooteco.subway.domain.member.Member; -import wooteco.subway.service.member.MemberService; -import wooteco.subway.web.member.AuthorizationExtractor; -import wooteco.subway.web.member.InvalidAuthenticationException; - -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; - -@Component -public class BasicAuthInterceptor implements HandlerInterceptor { - private AuthorizationExtractor authExtractor; - private MemberService memberService; - - public BasicAuthInterceptor(AuthorizationExtractor authExtractor, MemberService memberService) { - this.authExtractor = authExtractor; - this.memberService = memberService; - } - - @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { - // TODO: Authorization 헤더를 통해 Basic 값을 추출 (authExtractor.extract() 메서드 활용) - - // TODO: 추출한 Basic 값을 Base64를 통해 email과 password 값 추출(Base64.getDecoder().decode() 메서드 활용) - - String email = ""; - String password = ""; - - Member member = memberService.findMemberByEmail(email); - if (!member.checkPassword(password)) { - throw new InvalidAuthenticationException("올바르지 않은 이메일과 비밀번호 입력"); - } - - request.setAttribute("loginMemberEmail", email); - return true; - } -} diff --git a/src/main/java/wooteco/subway/web/member/interceptor/BearerAuthInterceptor.java b/src/main/java/wooteco/subway/web/member/interceptor/BearerAuthInterceptor.java index bd3c1bb53..7fb3183dd 100644 --- a/src/main/java/wooteco/subway/web/member/interceptor/BearerAuthInterceptor.java +++ b/src/main/java/wooteco/subway/web/member/interceptor/BearerAuthInterceptor.java @@ -11,8 +11,8 @@ @Component public class BearerAuthInterceptor implements HandlerInterceptor { - private AuthorizationExtractor authExtractor; - private JwtTokenProvider jwtTokenProvider; + private final AuthorizationExtractor authExtractor; + private final JwtTokenProvider jwtTokenProvider; public BearerAuthInterceptor(AuthorizationExtractor authExtractor, JwtTokenProvider jwtTokenProvider) { this.authExtractor = authExtractor; @@ -22,12 +22,11 @@ public BearerAuthInterceptor(AuthorizationExtractor authExtractor, JwtTokenProvi @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { - // TODO: Authorization 헤더를 통해 Bearer 값을 추출 (authExtractor.extract() 메서드 활용) - - // TODO: 추출한 토큰값의 유효성 검사 (jwtTokenProvider.validateToken() 메서드 활용) - - // TODO: 추출한 토큰값에서 email 정보 추출 (jwtTokenProvider.getSubject() 메서드 활용) - String email = ""; + String bearer = authExtractor.extract(request, "Bearer"); + if (!jwtTokenProvider.validateToken(bearer)) { + return false; + } + String email = jwtTokenProvider.getSubject(bearer); request.setAttribute("loginMemberEmail", email); return true; @@ -42,7 +41,9 @@ public void postHandle(HttpServletRequest request, } @Override - public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { + public void afterCompletion(HttpServletRequest request, + HttpServletResponse response, + Object handler, Exception ex) throws Exception { } } diff --git a/src/main/java/wooteco/subway/web/member/interceptor/SessionInterceptor.java b/src/main/java/wooteco/subway/web/member/interceptor/SessionInterceptor.java deleted file mode 100644 index 1c90fdbbd..000000000 --- a/src/main/java/wooteco/subway/web/member/interceptor/SessionInterceptor.java +++ /dev/null @@ -1,21 +0,0 @@ -package wooteco.subway.web.member.interceptor; - -import org.apache.logging.log4j.util.Strings; -import org.springframework.stereotype.Component; -import org.springframework.web.servlet.HandlerInterceptor; - -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; - -@Component -public class SessionInterceptor implements HandlerInterceptor { - @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { - String email = (String) request.getSession().getAttribute("loginMemberEmail"); - if (Strings.isNotBlank(email)) { - request.setAttribute("loginMemberEmail", email); - } - - return true; - } -} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index b74084067..0875d5d1e 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -38,4 +38,11 @@ create table if not exists MEMBER primary key(id) ); --- // TODO 즐겨찾기 테이블 스키마 추가 +create table if not exists FAVORITE +( + id bigint auto_increment not null, + member_id bigint not null, + departure_id bigint not null, + arrival_id bigint not null, + primary key(id) +); diff --git a/src/main/resources/static/service/api/index.js b/src/main/resources/static/service/api/index.js index 02a7f5731..78d0985c3 100644 --- a/src/main/resources/static/service/api/index.js +++ b/src/main/resources/static/service/api/index.js @@ -1,50 +1,154 @@ const METHOD = { - PUT() { + GET_WITH_AUTH() { return { - method: 'PUT' - } + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: localStorage.getItem("jwt") || "" + } + }; + }, + PUT(data) { + return { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: localStorage.getItem("jwt") || "" + }, + body: JSON.stringify({ + ...data + }) + }; }, DELETE() { return { - method: 'DELETE' - } + method: "DELETE", + headers: { + Authorization: localStorage.getItem("jwt") || "" + } + }; }, POST(data) { return { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json' + "Content-Type": "application/json", + Authorization: localStorage.getItem("jwt") || "" }, body: JSON.stringify({ ...data }) - } + }; + }, + DELETE_WITH_JSON(data) { + return { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: localStorage.getItem("jwt") || "" + }, + body: JSON.stringify({ + ...data + }) + }; } -} +}; const api = (() => { - const request = (uri, config) => fetch(uri, config) - const requestWithJsonData = (uri, config) => fetch(uri, config).then(data => data.json()) + const request = (uri, config) => fetch(uri, config).then(response => { + if (!response.ok) { + response.json().then(response => alert(response.errorMessage)) + return; + } + }); + + const requestWithJsonData = (uri, config) => + fetch(uri, config).then(response => { + if (!response.ok) { + response.json().then(response => alert(response.errorMessage)) + return; + } + return response.json().catch(() => { + throw new Error("json fail"); + }); + }); + + const requestWithNoContent = (uri, config) => fetch(uri, config).then(response => { + if (!response.ok) { + response.json().then(response => alert(response.errorMessage)) + return; + } + }); + + const member = { + get(id) { + return requestWithJsonData(`/members/${id}`); + }, + create(newMember) { + return request(`/members`, METHOD.POST(newMember)); + }, + update(id, updatedData) { + return request(`/members/${id}`, METHOD.PUT(updatedData)); + }, + delete(id) { + return request(`/members/${id}`, METHOD.DELETE()); + }, + login(loginInfo) { + return requestWithJsonData(`/oauth/token`, METHOD.POST(loginInfo)); + } + }; + + const loginMember = { + get() { + return requestWithJsonData(`/me/bearer`, METHOD.GET_WITH_AUTH()); + }, + update(updatedInfo) { + return request(`/me/bearer`, METHOD.PUT(updatedInfo)); + }, + delete() { + return request(`/me/bearer`, METHOD.DELETE()); + } + }; const line = { getAll() { - return request(`/lines/detail`) + return request(`/lines/detail`); }, getAllDetail() { - return requestWithJsonData(`/lines/detail`) + return requestWithJsonData(`/lines/detail`); } - } + }; const path = { find(params) { - return requestWithJsonData(`/paths?source=${params.source}&target=${params.target}&type=${params.type}`) + return requestWithJsonData( + `/paths?source=${params.source}&target=${params.target}&type=${params.type}` + ); } - } + }; + + const favorite = { + create(favoritePath) { + return request(`/favorites`, METHOD.POST(favoritePath)); + }, + get(id) { + return requestWithJsonData(`/favorites/${id}`); + }, + getAll() { + return requestWithJsonData(`/favorites`, METHOD.GET_WITH_AUTH()); + }, + delete(body) { + return requestWithNoContent(`/favorites`, METHOD.DELETE_WITH_JSON(body)); + } + }; return { + member, + loginMember, line, - path - } -})() + path, + favorite + }; +})(); -export default api +export default api; diff --git a/src/main/resources/static/service/css/app.css b/src/main/resources/static/service/css/app.css index 4fc95b937..449265f37 100644 --- a/src/main/resources/static/service/css/app.css +++ b/src/main/resources/static/service/css/app.css @@ -47,6 +47,10 @@ body.modal-active { right: 10px; } +.top-5px { + top: 5px; +} + .top-50 { top: 50%; } @@ -64,4 +68,14 @@ body.modal-active { } .max-width-md { max-width: 960px; -} \ No newline at end of file +} + +.dropdown:hover .dropdown-menu { + display: block; + right: 0; +} + +.login-button { + margin-top: 2px; + right: 30px; +} diff --git a/src/main/resources/static/service/js/App.js b/src/main/resources/static/service/js/App.js index 5b9ccf6d2..daa9d7d58 100644 --- a/src/main/resources/static/service/js/App.js +++ b/src/main/resources/static/service/js/App.js @@ -1,10 +1,33 @@ -import { initNavigation } from '../utils/templates.js' +import { initNavigation } from "../utils/templates.js"; +import api from "../api/index.js"; +import {EVENT_TYPE} from "../utils/constants.js"; function SubwayApp() { + const renderNavigation = () => { + const jwt = localStorage.getItem("jwt"); + if (jwt) { + api.loginMember + .get() + .then(member => { + if (member) { + initNavigation(member); + document.querySelector("#logout-button").addEventListener(EVENT_TYPE.CLICK, (event) => { + event.preventDefault(); + localStorage.setItem("jwt", ""); + location.href = "/"; + }); + } + }) + .catch(() => initNavigation()); + } else { + initNavigation(); + } + }; + this.init = () => { - initNavigation() - } + renderNavigation(); + }; } -const subwayApp = new SubwayApp() -subwayApp.init() +const subwayApp = new SubwayApp(); +subwayApp.init(); diff --git a/src/main/resources/static/service/js/views/Favorite.js b/src/main/resources/static/service/js/views/Favorite.js index 8368bc827..3bcc80fb0 100644 --- a/src/main/resources/static/service/js/views/Favorite.js +++ b/src/main/resources/static/service/js/views/Favorite.js @@ -1,18 +1,50 @@ -import { edgeItemTemplate } from '../../utils/templates.js' -import { defaultFavorites } from '../../utils/subwayMockData.js' +import {edgeItemTemplate} from "../../utils/templates.js"; +import api from "../../api/index.js"; +import showSnackbar from "../../lib/snackbar/index.js"; +import {ERROR_MESSAGE, EVENT_TYPE, SUCCESS_MESSAGE} from "../../utils/constants.js"; function Favorite() { - const $favoriteList = document.querySelector('#favorite-list') + const $favoriteList = document.querySelector("#favorite-list"); - const loadFavoriteList = () => { - const template = defaultFavorites.map(edge => edgeItemTemplate(edge)).join('') - $favoriteList.insertAdjacentHTML('beforeend', template) - } + const initFavoriteList = async () => { + try { + const template = await api.favorite + .getAll() + .then(favorites => + favorites.map(edge => edgeItemTemplate(edge)).join("") + ); + $favoriteList.innerHTML = template; + } catch (e) { + showSnackbar(ERROR_MESSAGE.COMMON); + } + }; + + const onDeleteHandler = async event => { + const $target = event.target; + const isDeleteButton = $target.classList.contains("mdi-delete"); + if (!isDeleteButton) { + return; + } + try { + const departure = $target.closest(".edge-item").dataset.edgeDeparture; + const arrival = $target.closest(".edge-item").dataset.edgeArrival; + await api.favorite.delete({departure, arrival}); + await initFavoriteList(); + showSnackbar(SUCCESS_MESSAGE.DELETE); + } catch (e) { + showSnackbar(ERROR_MESSAGE.COMMON); + } + }; + + const initEventListener = () => { + $favoriteList.addEventListener(EVENT_TYPE.CLICK, onDeleteHandler); + }; this.init = () => { - loadFavoriteList() - } + initFavoriteList(); + initEventListener(); + }; } -const favorite = new Favorite() -favorite.init() +const favorite = new Favorite(); +favorite.init(); \ No newline at end of file diff --git a/src/main/resources/static/service/js/views/Index.js b/src/main/resources/static/service/js/views/Index.js new file mode 100644 index 000000000..5eb45172b --- /dev/null +++ b/src/main/resources/static/service/js/views/Index.js @@ -0,0 +1,34 @@ +import {EVENT_TYPE} from "../../utils/constants.js"; + +function Index() { + const $optionsContainer = document.querySelector(".options-container"); + + const onLogoutHandler = (event) => { + event.preventDefault(); + localStorage.setItem("jwt", ""); + location.href = "/"; + } + + this.init = () => { + const jwt = localStorage.getItem("jwt"); + if (!!jwt && jwt !== "") { + const loginTemplate = `
  • + 로그아웃 +
  • ` + $optionsContainer.insertAdjacentHTML("afterbegin", loginTemplate); + document.querySelector("#index-logout").addEventListener(EVENT_TYPE.CLICK, onLogoutHandler); + } else { + const notLoginTemplate = `
  • + 로그인 +
  • +
  • + 회원가입 +
  • ` + $optionsContainer.insertAdjacentHTML("afterbegin", notLoginTemplate); + } + } +} + + +const index = new Index(); +index.init(); \ No newline at end of file diff --git a/src/main/resources/static/service/js/views/Join.js b/src/main/resources/static/service/js/views/Join.js new file mode 100644 index 000000000..e8cc8c042 --- /dev/null +++ b/src/main/resources/static/service/js/views/Join.js @@ -0,0 +1,57 @@ +import { EVENT_TYPE, ERROR_MESSAGE } from "../../utils/constants.js"; +import showSnackbar from "../../lib/snackbar/index.js"; +import api from "../../api/index.js"; + +function Join() { + const $joinButton = document.querySelector("#join-button"); + const $email = document.querySelector("#email"); + const $name = document.querySelector("#name"); + const $password = document.querySelector("#password"); + const $passwordCheck = document.querySelector("#password-check"); + + const onJoinHandler = async event => { + event.preventDefault(); + if (isValid()) { + const newMember = { + email: $email.value, + name: $name.value, + password: $password.value + }; + api.member + .create(newMember) + .then((response) => { + if (response.ok) { + location.href = "/"; + } else { + response.json().then(response => { + alert(response.errorMessage); + }); + } + }) + .catch(error => console.log(error)); + } + }; + + const isValid = () => { + const email = $email.value; + const name = $name.value; + const password = $password.value; + const passwordCheck = $passwordCheck.value; + if (!email || !name || !password) { + showSnackbar(ERROR_MESSAGE.COMMON); + return; + } + if (password !== passwordCheck) { + showSnackbar(ERROR_MESSAGE.PASSWORD_CHECK); + return; + } + return true; + }; + + this.init = () => { + $joinButton.addEventListener(EVENT_TYPE.CLICK, onJoinHandler); + }; +} + +const join = new Join(); +join.init(); diff --git a/src/main/resources/static/service/js/views/Login.js b/src/main/resources/static/service/js/views/Login.js index 549242697..6bce551c9 100644 --- a/src/main/resources/static/service/js/views/Login.js +++ b/src/main/resources/static/service/js/views/Login.js @@ -1,21 +1,39 @@ -import { EVENT_TYPE, ERROR_MESSAGE } from '../../utils/constants.js' +import { EVENT_TYPE, ERROR_MESSAGE } from "../../utils/constants.js"; +import showSnackbar from "../../lib/snackbar/index.js"; +import api from "../../api/index.js"; function Login() { - const $loginButton = document.querySelector('#login-button') + const $loginButton = document.querySelector("#login-button"); + const $email = document.querySelector("#email"); + const $password = document.querySelector("#password"); + const onLogin = event => { - event.preventDefault() - const emailValue = document.querySelector('#email').value - const passwordValue = document.querySelector('#password').value - if (!emailValue && !passwordValue) { - Snackbar.show({ text: ERROR_MESSAGE.LOGIN_FAIL, pos: 'bottom-center', showAction: false, duration: 2000 }) - return + event.preventDefault(); + const email = $email.value; + const password = $password.value; + if (!email && !password) { + showSnackbar(ERROR_MESSAGE.COMMON); + return; } - } + const loginMember = { + email: email, + password: password + }; + api.member + .login(loginMember) + .then(jwt => { + if (!!jwt) { + localStorage.setItem("jwt", `${jwt.tokenType} ${jwt.accessToken}`); + location.href = "/"; + } + }) + .catch(error => console.log(error)); + }; this.init = () => { - $loginButton.addEventListener(EVENT_TYPE.CLICK, onLogin) - } + $loginButton.addEventListener(EVENT_TYPE.CLICK, onLogin); + }; } -const login = new Login() -login.init() +const login = new Login(); +login.init(); diff --git a/src/main/resources/static/service/js/views/MyPage.js b/src/main/resources/static/service/js/views/MyPage.js new file mode 100644 index 000000000..f43de5fe1 --- /dev/null +++ b/src/main/resources/static/service/js/views/MyPage.js @@ -0,0 +1,25 @@ +import api from "../../api/index.js"; + +function MyPage() { + const $memberEmail = document.querySelector('#member-email'); + const $memberName = document.querySelector('#member-name'); + + const initMemberDetail = async () => { + const memberDetail = await api.loginMember.get().catch(() => { + alert("로그인 해주세요."); + return location.href = "/login"; + } + ); + + $memberEmail.innerText = memberDetail.email; + $memberName.innerText = memberDetail.name; + } + + this.init = () => { + initMemberDetail(); + }; +} + +const myPage = new MyPage(); + +myPage.init(); \ No newline at end of file diff --git a/src/main/resources/static/service/js/views/MyPageEdit.js b/src/main/resources/static/service/js/views/MyPageEdit.js new file mode 100644 index 000000000..f316953fa --- /dev/null +++ b/src/main/resources/static/service/js/views/MyPageEdit.js @@ -0,0 +1,65 @@ +import { + EVENT_TYPE, + ERROR_MESSAGE, + SUCCESS_MESSAGE +} from "../../utils/constants.js"; +import api from "../../api/index.js"; +import showSnackbar from "../../lib/snackbar/index.js"; + +function MyInfo() { + const $email = document.querySelector("#email"); + const $name = document.querySelector("#name"); + const $password = document.querySelector("#password"); + const $signOutButton = document.querySelector("#sign-out-button"); + const $updateButton = document.querySelector("#update-button"); + + const onSignOutHandler = event => { + event.preventDefault(); + if (confirm("정말 탈퇴하시겠습니까?")) { + api.loginMember + .delete() + .then(() => { + localStorage.setItem("jwt", ""); + location.href = "/"; + }) + .catch(() => showSnackbar(ERROR_MESSAGE.COMMON)); + } + }; + + const onUpdateHandler = event => { + event.preventDefault(); + const updatedInfo = { + name: $name.value, + password: $password.value + }; + api.loginMember + .update(updatedInfo) + .then((response) => { + if (!response.ok) { + throw new Error(); + } + alert(SUCCESS_MESSAGE.SAVE); + location.href = "/mypage"; + }) + .catch(() => showSnackbar(ERROR_MESSAGE.COMMON)); + }; + + const initMyInfo = () => { + api.loginMember + .get() + .then(member => { + $email.value = member.email; + $name.value = member.name; + }) + .catch(() => showSnackbar(ERROR_MESSAGE.COMMON)); + }; + + this.init = () => { + initMyInfo(); + $signOutButton.addEventListener(EVENT_TYPE.CLICK, onSignOutHandler); + $updateButton.addEventListener(EVENT_TYPE.CLICK, onUpdateHandler); + }; +} + +const myInfo = new MyInfo(); +myInfo.init(); diff --git a/src/main/resources/static/service/js/views/Search.js b/src/main/resources/static/service/js/views/Search.js index 45d42b747..d7f3c00b5 100644 --- a/src/main/resources/static/service/js/views/Search.js +++ b/src/main/resources/static/service/js/views/Search.js @@ -1,7 +1,6 @@ -import { EVENT_TYPE } from '../../utils/constants.js' +import {ERROR_MESSAGE, EVENT_TYPE, PATH_TYPE} from '../../utils/constants.js' import api from '../../api/index.js' -import { searchResultTemplate } from '../../utils/templates.js' -import { PATH_TYPE, ERROR_MESSAGE } from '../../utils/constants.js' +import {searchResultTemplate} from '../../utils/templates.js' function Search() { const $departureStationName = document.querySelector('#departure-station-name') @@ -35,23 +34,25 @@ function Search() { getSearchResult(PATH_TYPE.DURATION) } - const getSearchResult = pathType => { + const getSearchResult = async pathType => { const searchInput = { source: $departureStationName.value, target: $arrivalStationName.value, type: pathType } - api.path + await api.path .find(searchInput) .then(data => showSearchResult(data)) .catch(error => alert(ERROR_MESSAGE.COMMON)) } - const onToggleFavorite = event => { + const onToggleFavorite = async event => { event.preventDefault() const isFavorite = $favoriteButton.classList.contains('mdi-star') const classList = $favoriteButton.classList + await api.favorite.create({"departure":$departureStationName.value , "arrival":$arrivalStationName.value}); + if (isFavorite) { classList.add('mdi-star-outline') classList.add('text-gray-600') diff --git a/src/main/resources/static/service/lib/snackbar/index.js b/src/main/resources/static/service/lib/snackbar/index.js new file mode 100644 index 000000000..20c3070f7 --- /dev/null +++ b/src/main/resources/static/service/lib/snackbar/index.js @@ -0,0 +1,10 @@ +import Snackbar from "./snackbar.js"; + +export default function showSnackbar(message) { + Snackbar.show({ + text: message, + pos: "bottom-center", + showAction: false, + duration: 2000 + }); +} diff --git a/src/main/resources/static/service/lib/snackbar/snackbar.css b/src/main/resources/static/service/lib/snackbar/snackbar.css new file mode 100644 index 000000000..ac0857802 --- /dev/null +++ b/src/main/resources/static/service/lib/snackbar/snackbar.css @@ -0,0 +1,99 @@ +.snackbar-container { + transition: all 500ms ease; + transition-property: top, right, bottom, left, opacity; + font-family: Roboto, sans-serif; + font-size: 14px; + min-height: 14px; + background-color: #070b0e; + position: fixed; + display: flex; + justify-content: space-between; + align-items: center; + color: white; + line-height: 22px; + padding: 18px 24px; + bottom: -100px; + top: -100px; + opacity: 0; + z-index: 9999; +} + +.snackbar-container p { + width: 100%; + text-align: center; +} + +.snackbar-container .action { + background: inherit; + display: inline-block; + border: none; + font-size: inherit; + text-transform: uppercase; + color: #4caf50; + padding: 0; + min-width: min-content; + cursor: pointer; +} + +@media (min-width: 640px) { + .snackbar-container { + min-width: 288px; + max-width: 568px; + display: inline-flex; + border-radius: 2px; + } +} + +@media (max-width: 640px) { + .snackbar-container { + left: 0; + right: 0; + width: 100%; + } +} + +.snackbar-pos.bottom-center { + top: auto !important; + bottom: 0; + left: 50%; + transform: translate(-50%, 0); +} + +.snackbar-pos.bottom-left { + top: auto !important; + bottom: 0; + left: 0; +} + +.snackbar-pos.bottom-right { + top: auto !important; + bottom: 0; + right: 0; +} + +.snackbar-pos.top-left { + bottom: auto !important; + top: 0; + left: 0; +} + +.snackbar-pos.top-center { + bottom: auto !important; + top: 0; + left: 50%; + transform: translate(-50%, 0); +} + +.snackbar-pos.top-right { + bottom: auto !important; + top: 0; + right: 0; +} + +@media (max-width: 640px) { + .snackbar-pos.bottom-center, + .snackbar-pos.top-center { + left: 0; + transform: none; + } +} diff --git a/src/main/resources/static/service/lib/snackbar/snackbar.js b/src/main/resources/static/service/lib/snackbar/snackbar.js new file mode 100644 index 000000000..742f2218d --- /dev/null +++ b/src/main/resources/static/service/lib/snackbar/snackbar.js @@ -0,0 +1,173 @@ +/*! + * Snackbar v0.1.11 + * http://polonel.com/Snackbar + * + * Copyright 2018 Chris Brame and other contributors + * Released under the MIT license + * https://github.com/polonel/Snackbar/blob/master/LICENSE + */ + +var Snackbar = (function() { + var Snackbar = {}; + + Snackbar.current = null; + var $defaults = { + text: "Default Text", + textColor: "#FFFFFF", + width: "auto", + showAction: true, + actionText: "Dismiss", + actionTextColor: "#4CAF50", + showSecondButton: false, + secondButtonText: "", + secondButtonTextColor: "#4CAF50", + backgroundColor: "#323232", + pos: "bottom-left", + duration: 5000, + customClass: "", + onActionClick: function(element) { + element.style.opacity = 0; + }, + onSecondButtonClick: function(element) {}, + onClose: function(element) {} + }; + + Snackbar.show = function($options) { + var options = Extend(true, $defaults, $options); + + if (Snackbar.current) { + Snackbar.current.style.opacity = 0; + setTimeout( + function() { + var $parent = this.parentElement; + if ($parent) + // possible null if too many/fast Snackbars + $parent.removeChild(this); + }.bind(Snackbar.current), + 500 + ); + } + + Snackbar.snackbar = document.createElement("div"); + Snackbar.snackbar.className = "snackbar-container " + options.customClass; + Snackbar.snackbar.style.width = options.width; + var $p = document.createElement("p"); + $p.style.margin = 0; + $p.style.padding = 0; + $p.style.color = options.textColor; + $p.style.fontSize = "14px"; + $p.style.fontWeight = 300; + $p.style.lineHeight = "1em"; + $p.innerHTML = options.text; + Snackbar.snackbar.appendChild($p); + Snackbar.snackbar.style.background = options.backgroundColor; + + if (options.showSecondButton) { + var secondButton = document.createElement("button"); + secondButton.className = "action"; + secondButton.innerHTML = options.secondButtonText; + secondButton.style.color = options.secondButtonTextColor; + secondButton.addEventListener("click", function() { + options.onSecondButtonClick(Snackbar.snackbar); + }); + Snackbar.snackbar.appendChild(secondButton); + } + + if (options.showAction) { + var actionButton = document.createElement("button"); + actionButton.className = "action"; + actionButton.innerHTML = options.actionText; + actionButton.style.color = options.actionTextColor; + actionButton.addEventListener("click", function() { + options.onActionClick(Snackbar.snackbar); + }); + Snackbar.snackbar.appendChild(actionButton); + } + + if (options.duration) { + setTimeout( + function() { + if (Snackbar.current === this) { + Snackbar.current.style.opacity = 0; + // When natural remove event occurs let's move the snackbar to its origins + Snackbar.current.style.top = "-100px"; + Snackbar.current.style.bottom = "-100px"; + } + }.bind(Snackbar.snackbar), + options.duration + ); + } + + Snackbar.snackbar.addEventListener( + "transitionend", + function(event, elapsed) { + if (event.propertyName === "opacity" && this.style.opacity === "0") { + if (typeof options.onClose === "function") options.onClose(this); + + this.parentElement.removeChild(this); + if (Snackbar.current === this) { + Snackbar.current = null; + } + } + }.bind(Snackbar.snackbar) + ); + + Snackbar.current = Snackbar.snackbar; + + document.body.appendChild(Snackbar.snackbar); + var $bottom = getComputedStyle(Snackbar.snackbar).bottom; + var $top = getComputedStyle(Snackbar.snackbar).top; + Snackbar.snackbar.style.opacity = 1; + Snackbar.snackbar.className = + "snackbar-container " + + options.customClass + + " snackbar-pos " + + options.pos; + }; + + Snackbar.close = function() { + if (Snackbar.current) { + Snackbar.current.style.opacity = 0; + } + }; + + // Pure JS Extend + // http://gomakethings.com/vanilla-javascript-version-of-jquery-extend/ + var Extend = function() { + var extended = {}; + var deep = false; + var i = 0; + var length = arguments.length; + + if (Object.prototype.toString.call(arguments[0]) === "[object Boolean]") { + deep = arguments[0]; + i++; + } + + var merge = function(obj) { + for (var prop in obj) { + if (Object.prototype.hasOwnProperty.call(obj, prop)) { + if ( + deep && + Object.prototype.toString.call(obj[prop]) === "[object Object]" + ) { + extended[prop] = Extend(true, extended[prop], obj[prop]); + } else { + extended[prop] = obj[prop]; + } + } + } + }; + + for (; i < length; i++) { + var obj = arguments[i]; + merge(obj); + } + + return extended; + }; + + return Snackbar; +})(); + +export default Snackbar; diff --git a/src/main/resources/static/service/utils/constants.js b/src/main/resources/static/service/utils/constants.js index 945237b4b..35924ebef 100644 --- a/src/main/resources/static/service/utils/constants.js +++ b/src/main/resources/static/service/utils/constants.js @@ -1,12 +1,20 @@ export const EVENT_TYPE = { - CLICK: 'click' -} + CLICK: "click" +}; + +export const SUCCESS_MESSAGE = { + SAVE: "😁 정보가 반영되었습니다.", + DELETE: "😁 정보가 삭제되었습니다.", +}; export const ERROR_MESSAGE = { - LOGIN_FAIL: '😭 로그인이 실패했습니다. 다시 시도해주세요.' -} + COMMON: "🕵️‍♂️ 빠진 항목이 없는지 확인해 주세요.", + PASSWORD_CHECK: "🕵️‍♂️ 패스워드가 일치하지 않습니다.", + LOGIN_FAIL: "😭 로그인이 실패했습니다. 다시 시도해주세요.", + JOIN_FAIL: "😭 회원가입이 실패했습니다. " +}; export const PATH_TYPE = { - DISTANCE: 'DISTANCE', - DURATION: 'DURATION' -} + DISTANCE: "DISTANCE", + DURATION: "DURATION" +}; diff --git a/src/main/resources/static/service/utils/templates.js b/src/main/resources/static/service/utils/templates.js index e22d5c7e7..1818abce2 100644 --- a/src/main/resources/static/service/utils/templates.js +++ b/src/main/resources/static/service/utils/templates.js @@ -1,42 +1,78 @@ export const listItemTemplate = data => `
    ${data.name} -
    ` + `; -export const navTemplate = ``; export const subwayLinesItemTemplate = line => { - const stations = line.stations ? line.stations.map(station => listItemTemplate(station)).join('') : null + const stations = line.stations + ? line.stations.map(station => listItemTemplate(station)).join("") + : null; return `
    -
    ${line.name}
    +
    ${line.name}
    ${stations}
    -
    ` -} + `; +}; export const searchResultTemplate = result => { - const lastIndex = result.stations.length - 1 - const pathResultTemplate = result.stations.map((station, index) => pathStationTemplate(station.name, index, lastIndex)).join('') + const lastIndex = result.stations.length - 1; + const pathResultTemplate = result.stations + .map((station, index) => + pathStationTemplate(station.name, index, lastIndex) + ) + .join(""); return `
    @@ -55,21 +91,45 @@ export const searchResultTemplate = result => { ${pathResultTemplate}
    -
    ` -} + `; +}; export const pathStationTemplate = (name, index, lastIndex) => { return ` ${ index === 0 || index === lastIndex - ? `${index === lastIndex ? `` : ``} + ? `${ + index === lastIndex + ? `` + : `` + } ${name}` : ` ${name} ` - }` -} + }`; +}; + +export const initNavigation = member => { + document + .querySelector("body") + .insertAdjacentHTML("afterBegin", navTemplate(member)); +}; -export const initNavigation = () => { - document.querySelector('body').insertAdjacentHTML('afterBegin', navTemplate) -} +export const edgeItemTemplate = edge => { + return `
  • + + ${ + edge.departure ? edge.departure : "출발역" + } + + ${ + edge.arrival ? edge.arrival : "도착역" + } + +
  • `; +}; diff --git a/src/main/resources/templates/service/favorite.html b/src/main/resources/templates/service/favorite.html index d05f1f655..6243e0bb7 100644 --- a/src/main/resources/templates/service/favorite.html +++ b/src/main/resources/templates/service/favorite.html @@ -1,26 +1,38 @@ - - - RunningMap - - - - - - - -
    -
    -
    -
    즐겨찾기
    -
    -
      -
      -
      + + + RunningMap + + + + + + + + +
      +
      +
      +
      즐겨찾기
      +
      +
        - - - - +
        +
        + + + + \ No newline at end of file diff --git a/src/main/resources/templates/service/index.html b/src/main/resources/templates/service/index.html index 373106caf..cf513f3f3 100644 --- a/src/main/resources/templates/service/index.html +++ b/src/main/resources/templates/service/index.html @@ -3,9 +3,18 @@ RunningMap - - - + + + @@ -17,18 +26,10 @@ 내 성장의 최단거리를 찾아가는 실습 프로젝트!
        제공되는 템플릿을 활용해 프로젝트를 완성해 보세요!

        -
        + diff --git a/src/main/resources/templates/service/join.html b/src/main/resources/templates/service/join.html index 4d8f59512..5e8196bbe 100644 --- a/src/main/resources/templates/service/join.html +++ b/src/main/resources/templates/service/join.html @@ -3,18 +3,31 @@ RunningMap - - - + + + + -
        -
        +
        +
        회원가입
        -
        -
        -
        -
        -
        -
        -
        + diff --git a/src/main/resources/templates/service/login.html b/src/main/resources/templates/service/login.html index f20a4bdb0..a050b2c5a 100644 --- a/src/main/resources/templates/service/login.html +++ b/src/main/resources/templates/service/login.html @@ -3,19 +3,32 @@ RunningMap - - - + + + +
        -
        +
        로그인
        -
        -
        diff --git a/src/main/resources/templates/service/mypage-edit.html b/src/main/resources/templates/service/mypage-edit.html index ad4515458..ab012f5fe 100644 --- a/src/main/resources/templates/service/mypage-edit.html +++ b/src/main/resources/templates/service/mypage-edit.html @@ -3,18 +3,31 @@ RunningMap - - - + + + + -
        -
        -
        나의 정보 수정
        +
        +
        +
        나의 정보
        -
        -
        -
        -
        - - -
        - 저장 - - +
        + diff --git a/src/main/resources/templates/service/mypage.html b/src/main/resources/templates/service/mypage.html index 5e10d7091..2d7a81069 100644 --- a/src/main/resources/templates/service/mypage.html +++ b/src/main/resources/templates/service/mypage.html @@ -14,24 +14,32 @@
        나의 정보
        email
        -
        eastjun@woowahan.com
        +
        eastjun@woowahan.com
        name
        -
        eastjun
        +
        eastjun
        수정하기
        + diff --git a/src/main/resources/templates/service/search.html b/src/main/resources/templates/service/search.html index e0924309c..1d0b5c317 100644 --- a/src/main/resources/templates/service/search.html +++ b/src/main/resources/templates/service/search.html @@ -8,6 +8,7 @@ +
        @@ -17,11 +18,11 @@
        @@ -30,11 +31,11 @@
        @@ -46,8 +47,8 @@