5 次代码提交 f6bcb02346 ... 35b324608d

作者 SHA1 备注 提交日期
  sangwonlee 35b324608d 사용자 상세페이지 퍼블리싱 연동 및 게시글 조회 API 3 年之前
  Gayeon Park b1087757bb 사용자 페이지, 기타 수정 3 年之前
  Gayeon Park c088beec54 Merge branch 'dbs333' of http://wcollector.idatabank.com:5230/dbs333/RealWorld into dbs347 3 年之前
  Gayeon Park 7d34fce178 기타수정 3 年之前
  Gayeon Park 24cdd3ed10 게시물 상세 페이지 팔로우, 좋아요 기능 추가 3 年之前

+ 1 - 0
realworld/src/main/java/com/dbs/realworld/common/Views.java

@@ -5,6 +5,7 @@ public class Views {
     public static final String SIGNUP_FORM = "user/signup";               // 회원가입 폼
     public static final String SIGNIN_FORM = "user/signin";               // 로그인 폼
     public static final String USER_SETTING_FORM = "user/settings";       // 세팅 폼
+    public static final String USER_DETAIL = "user/userDetail";          // 세팅 폼
     public static final String ARTICLE_FORM = "article/article";          // 아티클 폼
     public static final String ARTICLE_DETAIL = "article/articleDetails"; // 아티클 상세
 }

+ 29 - 6
realworld/src/main/java/com/dbs/realworld/controller/ArticleController.java

@@ -165,8 +165,6 @@ public class ArticleController {
 
     /**
      * 게시물 수정
-     */
-    /**
      * https://stackoverflow.com/a/17376670
      * HTTP PATCH Request의 경우 바디에 아무 제약도 없다
      */
@@ -191,7 +189,7 @@ public class ArticleController {
 
 
     /**
-     * 게시물 조회
+     * 게시물 조회 in 메인 페이지
      * 커서 기반 페이징
      * @queryParam feed      your-feed or global-feed(디폴트)
      * @queryParam articleId cursor (디폴트 -1, -1인 경우 최초 데이터 조회)
@@ -212,10 +210,35 @@ public class ArticleController {
         Map<String, Object> data = new HashMap<>();
         data.put("articles", articleDTOs);
 
-        final int size = articleDTOs.size();
-        int lastArticleId = (size != 0) ? articleDTOs.get(size - 1).getId() : -1;
-        data.put("paging", this.articleService.calculatePagingInfo(articleDTOs, lastArticleId, feed, userId));
+        data.put("paging", this.articleService.calculatePagingInfo(articleDTOs, feed, userId));
+        
+        return ResponseEntity.ok().body(data);
+    }
+
+
+    /**
+     * 게시물 조회 in 사용자 상세 페이지
+     * 커서 기반 페이징
+     * @queryParam feed      your-feed or favorite-feed
+     * @queryParam articleId cursor (디폴트 -1, -1인 경우 최초 데이터 조회)
+     */
+    @GetMapping("/page/users/{userId}")
+    public ResponseEntity getMyArticles(HttpServletRequest request, 
+            @RequestParam(defaultValue = "your-feed") String feed,
+            @RequestParam(defaultValue = "-1") int articleId,
+            @PathVariable("userId") int userId) {
+        Map<String, Object> data = new HashMap<>();
         
+        UserDTO userDTO = this.userMapper.selectUserById(userId);
+        userDTO.setPassword("");
+        data.put("user", userDTO);
+
+        // int viewer = (int) request.getSession().getAttribute("ssId");
+        int viewer = 1;
+        List<ArticleDTO> myArticles = this.articleService.findMyArticles(viewer, userId, articleId, feed);
+        data.put("articles", myArticles);
+        data.put("paging", this.articleService.calculatePagingInfo(myArticles, feed, userId));
+
         return ResponseEntity.ok().body(data);
     }
 

+ 39 - 11
realworld/src/main/java/com/dbs/realworld/controller/UserController.java

@@ -1,12 +1,16 @@
 package com.dbs.realworld.controller;
 
+import java.util.List;
+
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpSession;
 
 import com.dbs.realworld.common.Views;
+import com.dbs.realworld.dto.ArticleDTO;
 import com.dbs.realworld.dto.FollowDTO;
 import com.dbs.realworld.dto.UserDTO;
 import com.dbs.realworld.mapper.UserMapper;
+import com.dbs.realworld.service.ArticleService;
 import com.dbs.realworld.service.UserService;
 
 import org.springframework.http.HttpStatus;
@@ -27,10 +31,12 @@ import org.springframework.web.bind.annotation.RequestBody;
 @RequestMapping("/user")
 public class UserController {
 
+    private final ArticleService articleService;
     private final UserService userService;
     private final UserMapper userMapper;
 
-    public UserController(UserService userService, UserMapper userMapper) {
+    public UserController(ArticleService articleService, UserService userService, UserMapper userMapper) {
+        this.articleService = articleService;
         this.userService = userService;
         this.userMapper = userMapper;
     }
@@ -148,16 +154,16 @@ public class UserController {
     @PatchMapping("/settings/{userId}")
     public ResponseEntity updateUserSetting(HttpServletRequest request, @PathVariable("userId") int userId, @RequestBody UserDTO userDTO, ModelMap model) {
         userDTO.setId(userId);
-        
-        /**
-         * 변경하려는 사용자의 이메일이 중복되지는 않는지? 체크해야 함
-         */
-        // UserDTO findedByEmail = this.userMapper.selectUserByEmail(userDTO.getEmail());
-        // if (findedByEmail != null && userId != findedByEmail.getId()) {
-            // 변경하려는 이메일이 이미 있는 경우 처리
-            // TODO 이메일이 중복되었다고 알려줘야 함
-            // return Views.USER_SETTING_FORM;
-        // }
+
+        // 이미 이메일을 사용자가 존재하며 그 이메일 사용자가 내가 아닌 경우
+        UserDTO findedByEmail = this.userMapper.selectUserByEmail(userDTO.getEmail());
+        if (findedByEmail != null && userId != findedByEmail.getId()) {
+            /**
+             * 이메일 중복
+             * https://blog.outsider.ne.kr/1121
+             */
+            return ResponseEntity.status(HttpStatus.CONFLICT).body("이미 사용중인 이메일 입니다.");
+        }
 
         this.userService.update(userDTO);
 
@@ -171,4 +177,26 @@ public class UserController {
 
         return ResponseEntity.ok().build();
     }
+
+
+    /**
+     * 사용자 상세 페이지 진입시
+     * feed는 기본 your-type으로 하여 최초 게시글 조회
+     */
+    @GetMapping("/{userId}")
+    public String getUserInfo(HttpServletRequest request, @PathVariable("userId") int userId, ModelMap model) {
+        UserDTO userDTO = this.userMapper.selectUserById(userId);
+        userDTO.setPassword("");
+        model.addAttribute("user", userDTO);
+        
+        int viewer = (int) request.getSession().getAttribute("ssId");
+        // int viewer = 9;          // delete
+        int articleId = -1;
+        // int articleId = 1000052; // delete
+        List<ArticleDTO> myArticles = this.articleService.findMyArticles(viewer, userId, articleId, "your-feed");
+        model.addAttribute("myArticles", myArticles);
+        model.addAttribute("paging", this.articleService.calculatePagingInfo(myArticles, "your-feed", userId));
+
+        return Views.USER_DETAIL;
+    }
 }

+ 7 - 0
realworld/src/main/java/com/dbs/realworld/mapper/ArticleMapper.java

@@ -12,8 +12,13 @@ import org.springframework.stereotype.Repository;
 @Mapper
 public interface ArticleMapper {
     void insertArticle(ArticleDTO dto);
+
     List<ArticleDTO> select(int articleId, String feed, int userId);
     Map<String, Object> selectPagingInfo(int articleId, String feed, int userId);
+
+    List<ArticleDTO> selectMyArticles(int viewer, int userId, int articleId);
+    List<ArticleDTO> selectFavoriteArticles(int viewer, int userId, int articleId);
+
     ArticleDTO selectArticleDetail(int articleId, int userId);
     ArticleDTO selectByArticleId(int articleId);
     void deleteByArticleId(int articleId);
@@ -22,4 +27,6 @@ public interface ArticleMapper {
     void insertFavorite(int userId, int articleId);
     void deleteFavorite(int userId, int articleId);
     void updateFavoriteNum(int articleId);
+
+    
 }

+ 1 - 0
realworld/src/main/java/com/dbs/realworld/mapper/UserMapper.java

@@ -12,6 +12,7 @@ public interface UserMapper {
     void insertUser(UserDTO dto);
     void update(UserDTO dto);
     UserDTO selectUserByEmail(String email);
+    UserDTO selectUserById(int id);
     void insertFollow(FollowDTO dto);
     void deleteFollow(FollowDTO dto);
 }

+ 30 - 11
realworld/src/main/java/com/dbs/realworld/service/ArticleService.java

@@ -9,7 +9,6 @@ import com.dbs.realworld.mapper.ArticleMapper;
 
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
 
 @Service
 public class ArticleService {
@@ -23,6 +22,7 @@ public class ArticleService {
         this.commentService = commentService;
     }
 
+
     // default: rollbackFor = {RuntimeExceeption.class, Error.class}
     // uncheckedException과 error 뿐만 아니라 모든 예외에 대해 롤백하고 싶은 경우
     // @Transactional(rollbackFor = Exception.class)
@@ -32,27 +32,48 @@ public class ArticleService {
     }
 
     
+    /**
+     * 사용자 상세 페이지에 배치될 아티클 리스트
+     * 피드 타입이 "your-type"인 경우 해당 사용자가 좋아요 한 게시물 리스트
+     * "favorite-type"인 경우 사용자가 좋아요 한 게시물 리스트
+     * @param viewer    조회자
+     * @param userId    타겟 사용자 아이디
+     * @param articleId 커서
+     * @param feed      피드 타입
+     * @return
+     */
+    public List<ArticleDTO> findMyArticles(int viewer, int userId, int articleId, String feed) {
+        List<ArticleDTO> articles = null;
+        if (feed.equals("your-feed")) {
+            articles = this.articleMapper.selectMyArticles(viewer, userId, articleId);
+        } else if (feed.equals("favorite-feed")) {
+            articles = this.articleMapper.selectFavoriteArticles(viewer, userId, articleId);
+        }
+        return articles;
+    }
+
+
     /**
      * 
      * @param feed  feed 타입, 'your-feed', 'global-feed'
      * @param id    cursor(articleId), 다음 페이지에 필요한 데이터를 위한 기준
      */
-    public List<ArticleDTO>  find(int articleId, String feed, int userId) {
+    public List<ArticleDTO> find(int articleId, String feed, int userId) {
         return this.articleMapper.select(articleId, feed, userId);
     }
 
 
-    public Map<String, Object> calculatePagingInfo(List<ArticleDTO> articleDTOs, int lastArticleId, String feed, int userId) {
+    public Map<String, Object> calculatePagingInfo(List<ArticleDTO> articleDTOs, String feed, int userId) {
+
+        final int size = articleDTOs.size();
+        int lastArticleId = (size != 0) ? articleDTOs.get(size - 1).getId() : -1;
+
         Map<String, Object> paging = this.articleMapper.selectPagingInfo(lastArticleId, feed, userId);
         paging.put("size", articleDTOs.size());
         if (lastArticleId == -1) {
             paging.put("isNext", false);
-            paging.put("firstId", null);
-            paging.put("lastId", null);
         } else {
             paging.put("isNext", ((Long) paging.get("nextItemNum")).intValue() > 0);
-            paging.put("firstId", articleDTOs.get(0).getId());
-            paging.put("lastId", articleDTOs.get(articleDTOs.size() - 1).getId());
         }
         return paging;
     }
@@ -71,8 +92,6 @@ public class ArticleService {
         List<CommentDTO> commentDTOs = this.commentService.findAllByArticleId(articleId);
         articleDTO.setComments(commentDTOs);
 
-
-
         return articleDTO;
     }
 
@@ -87,14 +106,14 @@ public class ArticleService {
     }
 
 
-    @Transactional(rollbackFor = Exception.class)
+    // @Transactional(rollbackFor = Exception.class)
     public void favorite(int userId, int articleId) {
         this.articleMapper.insertFavorite(userId, articleId);
         this.articleMapper.updateFavoriteNum(articleId);
     }
 
 
-    @Transactional(rollbackFor = Exception.class)
+    // @Transactional(rollbackFor = Exception.class)
     public void unfavorite(int userId, int articleId) {
         this.articleMapper.deleteFavorite(userId, articleId);
         this.articleMapper.updateFavoriteNum(articleId);

+ 92 - 13
realworld/src/main/resources/mybatis/mapper/user/ArticleMapper.xml

@@ -93,6 +93,80 @@
       ]]>
     </select>
 
+    
+    <select id="selectMyArticles" parameterType="hashmap" resultType="ArticleDTO">
+      <![CDATA[
+        SELECT
+          AR.id               AS id,
+          AR.title            AS title,
+          AR.subtitle         AS subtitle,
+          AR.content          AS content,
+          AR.tags             AS tags,
+          AR.create_datetime  AS created,
+          AR.favorite_num     AS favoriteNum,
+          AR.user_id          AS writerId,
+          USR.name            AS writerName,
+          USR.email           AS writerEmail,
+          (
+              SELECT COUNT(*) FROM article_like AL 
+              WHERE AL.user_id     = #{viewer}
+              AND   AL.article_id  = AR.id
+          )                   AS favorite
+          FROM article_mst AR
+          LEFT JOIN user_mst USR
+          ON AR.user_id = USR.id
+          WHERE 1=1
+          AND AR.user_id = #{userId}
+      ]]>
+      <if test='articleId != -1'>
+        <![CDATA[
+          AND AR.id < #{articleId}
+        ]]>
+      </if>
+      <![CDATA[
+        ORDER BY AR.create_datetime DESC
+        LIMIT 4
+      ]]>
+    </select>
+
+
+    <select id="selectFavoriteArticles" parameterType="hashmap" resultType="ArticleDTO">
+      <![CDATA[
+        SELECT
+          AR.id                AS id
+          ,AR.title            AS title
+          ,AR.subtitle         AS subtitle
+          ,AR.content          AS content
+          ,AR.tags             AS tags
+          ,AR.create_datetime  AS created
+          ,AR.favorite_num     AS favoriteNum
+          ,AR.user_id          AS writerId
+          ,USR.name            AS writerName
+          ,USR.email           AS writerEmail
+          ,(
+              SELECT COUNT(*) FROM article_like AL2 
+              WHERE AL2.user_id     = #{viewer}
+              AND   AL2.article_id  = AR.id
+          )                   AS favorite
+        FROM      article_mst AR
+        LEFT JOIN article_like AL
+        ON        AR.id = AL.article_id
+        LEFT JOIN user_mst USR
+        ON        AR.user_id = USR.id
+        WHERE 1=1
+        AND   AL.user_id = #{userId}
+      ]]>
+      <if test='articleId != -1'>
+        <![CDATA[
+          AND AR.id < #{articleId}
+        ]]>
+      </if>
+      <![CDATA[
+        ORDER BY AR.create_datetime DESC
+        LIMIT 4
+      ]]>
+    </select>
+
 
      <select id="selectPagingInfo" parameterType="hashmap" resultType="hashmap">
         <![CDATA[
@@ -101,28 +175,33 @@
             COUNT( IF(id < #{articleId}, 1, null) )  AS nextItemNum
           FROM article_mst AR
         ]]>
+        <if test='feed=="favorite-feed"'>
+          <![CDATA[
+            LEFT JOIN article_like AL
+            ON        AR.id = AL.article_id
+            WHERE     AL.user_id = #{userId}
+          ]]>
+        </if>
         <if test='feed == "your-feed"'>
           <![CDATA[
             WHERE     AR.user_id = #{userId}
-            GROUP BY  AR.user_id
           ]]>
         </if>
      </select>
 
-
     <select id="selectArticleDetail" parameterType="hashmap" resultType="ArticleDTO">
       <![CDATA[
         SELECT
-          USR.name            AS writerName,
-          USR.email           AS writerEmail,
-          AR.user_id          AS writerId,
-          AR.id               AS id,
-          AR.title            AS title,
-          AR.subtitle         AS subtitle,
-          AR.content          AS content,
-          AR.tags             AS tags,
-          AR.create_datetime  AS created,
-          AR.favorite_num     AS favoriteNum,
+          USR.name            AS writerName
+          ,USR.email           AS writerEmail
+          ,AR.user_id          AS writerId
+          ,AR.id               AS id
+          ,AR.title            AS title
+          ,AR.subtitle         AS subtitle
+          ,AR.content          AS content
+          ,AR.tags             AS tags
+          ,AR.create_datetime  AS created
+          ,AR.favorite_num     AS favoriteNum
       ]]>
       <if test='userId != -1'>
         <![CDATA[
@@ -130,7 +209,7 @@
               SELECT COUNT(*) FROM article_like AL 
               WHERE AL.user_id     = #{userId}
               AND   AL.article_id  = AR.id
-          )                   AS favorite,
+          )                   AS favorite
           (
               SELECT COUNT(*) FROM user_follow UF
               WHERE  UF.from_user = #{userId}

+ 22 - 7
realworld/src/main/resources/mybatis/mapper/user/UserMapper.xml

@@ -44,21 +44,36 @@
     <select id="selectUserByEmail" parameterType="String" resultType="UserDTO">
       <![CDATA[
         SELECT
-          id         AS id,
-          name       AS username,
-          email      AS email,
-          pwd        AS password,
-          short_bio  AS shortBio
+          id              AS id,
+          name            AS username,
+          email           AS email,
+          pwd             AS password,
+          short_bio       AS shortBio,
+          create_datetime AS created
         FROM user_mst
         WHERE
           email = #{email}
       ]]>
     </select>
 
+    <select id="selectUserById" parameterType="int" resultType="UserDTO">
+      <![CDATA[
+        SELECT
+          id              AS id,
+          name            AS username,
+          email           AS email,
+          pwd             AS password,
+          short_bio       AS shortBio,
+          create_datetime AS created
+        FROM user_mst
+        WHERE
+          id = #{id}
+      ]]>
+    </select>
+
     <insert id="insertFollow" parameterType="FollowDTO">
         <![CDATA[
-            INSERT 
-              INTO user_follow
+            INSERT IGNORE INTO user_follow
                    ( from_user,   to_user	)
             VALUES ( #{fromUser}, #{toUser} )
         ]]>

+ 164 - 40
realworld/src/main/webapp/WEB-INF/jsp/article/articleDetails.jsp

@@ -2,18 +2,100 @@
 <%@ taglib uri="http://www.springframework.org/tags/form" prefix="form" %>
 <%@ taglib uri="http://java.sun.com/jsp/jstl/fmt" prefix="fmt" %>
 <%@ taglib uri="http://www.springframework.org/tags" prefix="spring" %>
-<%@ taglib uri = "http://java.sun.com/jsp/jstl/functions" prefix = "fn" %>
+<%@ taglib uri="http://java.sun.com/jsp/jstl/functions" prefix="fn" %>
 <%@ page language="java" contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
 
 
 <jsp:include page="/WEB-INF/jsp/include/head.jsp"></jsp:include>
 <script>
-    function filter(classname) {
-        const buttons = document.querySelectorAll('button');
-        if("${ssIsLogin}" === "true") {
-            buttons.forEach(button => {
-                if(button.classList.contains(classname)) {
-                    button.classList.toggle('active');
+    const follow = 'Follow ' + "${articleDetail.writerName}";
+    const unfollow = 'Unfollow ' + "${articleDetail.writerName}";
+    const favorite = 'Favorite Article';
+    const unfavorite = 'Unfavorite Article';
+
+    function userFollow() {
+        const followBtn = document.querySelectorAll('.follow-btn');
+
+        if ("${ssIsLogin}" === "true") {
+            followBtn.forEach(button => {
+                if (button.classList.contains('active')) {
+                    button.querySelector('.follow').textContent = follow;
+                    fetch("/user/unfollow", {
+                        method: 'DELETE',
+                        body: JSON.stringify({
+                            fromUser: "${ssId}",
+                            toUser: "${articleDetail.writerId}"
+                        }),
+                        headers: {
+                            "Content-Type": "application/json"
+                        }
+                    })
+                        .then(response => {
+                            if (response.status === 200) {
+                                return button.classList.remove('active');
+                            }
+                        })
+                } else {
+                    button.querySelector('.follow').textContent = unfollow;
+                    fetch("/user/follow", {
+                        body: JSON.stringify({
+                            fromUser: "${ssId}",
+                            toUser: "${articleDetail.writerId}"
+                        }),
+                        method: 'POST',
+                        headers: {
+                            "Content-Type": "application/json"
+                        }
+                    })
+                        .then(response => {
+                            if (response.status === 201) {
+                                return button.classList.add('active');
+                            }
+                        })
+                }
+            })
+        } else {
+            location.href = "/user/signin"
+        }
+    }
+
+    function articleFavorite() {
+        const favoriteBtn = document.querySelectorAll('.favorite-btn');
+        let counts = parseInt(event.target.querySelector('.count').textContent);
+        let newCounts = counts + 1;
+
+        if ("${ssIsLogin}" === "true") {
+            favoriteBtn.forEach(button => {
+                if (button.classList.contains('active')) {
+                    button.querySelector('.favorite').textContent = favorite;
+                    button.classList.remove('active');
+
+                    fetch("/article/${articleDetail.id}/favorite", { method: 'DELETE' })
+                        .then(response => {
+                            if (response.status === 200) {
+                                return button.querySelector('.count').textContent = counts - 1;
+                            }
+                        })
+                } else {
+                    button.querySelector('.favorite').textContent = unfavorite;
+                    button.classList.add('active');
+
+                    fetch("/article/${articleDetail.id}/favorite", {
+                        body: JSON.stringify({
+                            artilceId: "${articleDetail.id}",
+                            userId: "${ssId}",
+                            created: new Date()
+                        }),
+                        method: 'POST',
+                        headers: {
+                            "Content-Type": "application/json"
+                        }
+                    })
+                        .then(response => {
+                            if (response.status === 201) {
+                                return button.querySelector('.count').textContent = newCounts;
+                            }
+                        })
                 }
             })
         } else {
@@ -47,11 +129,11 @@
                 "Content-Type": "application/json"
             }
         };
-        
-        if(commentInputArea.value !== ''){
+
+        if (commentInputArea.value !== '') {
             fetch('/article/${articleDetail.id}/comment', postOptions)
                 .then(response => {
-                    if(response.status === 201) {
+                    if (response.status === 201) {
                         return displayComments();
                     }
                 })
@@ -62,7 +144,7 @@
     function deleteComment(targetCommentId) {
         fetch("/article/${articleDetail.id}/comment/" + targetCommentId, { method: 'DELETE' })
             .then(response => {
-                if(response.status === 200) {
+                if (response.status === 200) {
                     return displayComments();
                 }
             })
@@ -76,7 +158,7 @@
                 }
             })
             .then(json => {
-                const { comments } = json;           
+                const { comments } = json;
                 const container = document.querySelector('.comments');
 
                 while (container.firstChild) {
@@ -107,7 +189,7 @@
                     const divComment = domParser.parseFromString(domStrComment, 'text/html').body.firstChild;
                     divComment.querySelector('.date-posted').textContent = new Date(comment.created).toLocaleString();
                     container.appendChild(divComment);
-                    const icons = document.querySelectorAll('.fa-trash-alt');
+                    const icons = document.querySelectorAll('.comment-footer .fa-trash-alt');
                     icons.forEach(icon => {
                         if (comment.userId == "${ssId}") {
                             icon.style.display = 'block';
@@ -115,29 +197,49 @@
                     })
                 })
             })
-        
+
     }
 
     window.onload = () => {
         const tags = document.querySelector('.tag-list');
         const tagString = "${articleDetail.tags}"
         const tagList = tagString.split(',');
-        if(tagString !== ''){
-            tags.innerHTML = tagList.map(tag => 
+        if (tagString !== '') {
+            tags.innerHTML = tagList.map(tag =>
                 `<li class="tag">\${tag}</li>`
             ).join('');
         }
 
         const date = "${articleDetail.created}"
-        document.querySelectorAll('.date').forEach(value => 
+        document.querySelectorAll('.date').forEach(value =>
             value.textContent = new Date(date).toLocaleString()
         )
 
+        const count = "${articleDetail.favoriteNum}";
+        document.querySelectorAll('.count').forEach(value => {
+            value.textContent = count;
+        })
+
+        const followString = document.querySelectorAll('.follow-btn');
+        followString.forEach(value => {
+            if(value.classList.contains('active')) {
+                value.querySelector('.follow').textContent = unfollow;
+            }
+        })
+
+        const favoriteString = document.querySelectorAll('.favorite-btn');
+        favoriteString.forEach(value => {
+            if (value.classList.contains('active')) {
+                value.querySelector('.favorite').textContent = unfavorite;
+            }
+        })
+
         displayComments();
     }
-    
+
 </script>
 </head>
+
 <body>
     <jsp:include page="/WEB-INF/jsp/include/header.jsp"></jsp:include>
 
@@ -163,23 +265,33 @@
                         <c:choose>
                             <c:when test="${articleDetail.writerId eq ssId}">
                                 <div class="buttons">
-                                    <a href="/article/${articleDetail.id}/edit" class="edit-article btn-sm">
+                                    <a href="/article/${articleDetail.id}/edit"
+                                        class="edit-article btn-sm">
                                         <i class="fas fa-pencil-alt"> Edit Article</i>
                                     </a>
-                                    <button class="delete-article btn-sm" onclick="deleteArticle('${articleDetail.id}')">
+                                    <button class="delete-article btn-sm"
+                                        onclick="deleteArticle('${articleDetail.id}')">
                                         <i class="fas fa-trash-alt"> Delete Article</i>
                                     </button>
                                 </div>
                             </c:when>
                             <c:otherwise>
                                 <div class="buttons">
-                                    <button class="follow-btn btn-sm btn-meta" onclick="filter('follow-btn')">
-                                        <i class="fas fa-plus"></i>&nbsp;Follow <c:out value="${articleDetail.writerName}"></c:out>
+                                    <button class="follow-btn btn-sm btn-meta <c:if test="${articleDetail.writerFollow eq true}">active</c:if>"
+                                        onclick="userFollow()"
+                                        >
+                                        <i class="fas fa-plus"></i>
+                                        <span class="follow">Follow <c:out
+                                                value="${articleDetail.writerName}"></c:out>
+                                            </span>
                                     </button>
-                                    <button class="favorite-btn btn-sm btn-meta" onclick="filter('favorite-btn')">
+                                    <button class="favorite-btn btn-sm btn-meta <c:if test="${articleDetail.favorite eq true}">active</c:if>"
+                                        onclick="articleFavorite()">
                                         <i class="fas fa-heart"></i>
-                                        <span> Favorite Article </span> 
-                                        <span class="count">(564)</span>
+                                        <span class="favorite"> Favorite Article </span> (
+                                        <span class="count">
+                                        </span>
+                                        )
                                     </button>
                                 </div>
                             </c:otherwise>
@@ -188,13 +300,13 @@
                 </div>
             </div>
         </section>
-    
+
         <div class="container main">
             <div class="article-content">
                 <div class="col-12">
                     <pre>
-                        <c:out value="${articleDetail.content}"></c:out>
-                    </pre>
+<c:out value="${articleDetail.content}"></c:out>
+</pre>
                     <ul class="tag-list">
                     </ul>
                 </div>
@@ -211,31 +323,40 @@
                                 <c:out value="${articleDetail.writerName}"></c:out>
                             </a>
                             <span class="date">
-                                
+
                             </span>
                         </div>
                         <div class="buttons">
-                            </div>
-                            <c:choose>
+                        </div>
+                        <c:choose>
                             <c:when test="${articleDetail.writerId eq ssId}">
                                 <div class="buttons">
-                                    <a href="/article/${articleDetail.id}/edit" class="edit-article btn-sm">
+                                    <a href="/article/${articleDetail.id}/edit"
+                                        class="edit-article btn-sm">
                                         <i class="fas fa-pencil-alt"> Edit Article</i>
                                     </a>
-                                    <button class="delete-article btn-sm" onclick="deleteArticle('${articleDetail.id}')">
+                                    <button class="delete-article btn-sm"
+                                        onclick="deleteArticle('${articleDetail.id}')">
                                         <i class="fas fa-trash-alt"> Delete Article</i>
                                     </button>
                                 </div>
                             </c:when>
                             <c:otherwise>
                                 <div class="buttons">
-                                    <button class="follow-btn btn-sm btn-meta" onclick="filter('follow-btn')">
-                                        <i class="fas fa-plus"></i>&nbsp;Follow <c:out value="${articleDetail.writerName}"></c:out>
+                                    <button class="follow-btn btn-sm btn-meta <c:if test="${articleDetail.writerFollow eq true}">active</c:if>" onclick="userFollow()"
+                                        >
+                                        <i class="fas fa-plus"></i>
+                                        <span class="follow">Follow <c:out
+                                                value="${articleDetail.writerName}"></c:out>
+                                            </span>
                                     </button>
-                                    <button class="favorite-btn btn-sm btn-meta" onclick="filter('favorite-btn')">
+                                    <button class="favorite-btn btn-sm btn-meta <c:if test="${articleDetail.favorite eq true}">active</c:if>"
+                                        onclick="articleFavorite()">
                                         <i class="fas fa-heart"></i>
-                                        <span> Favorite Article </span> 
-                                        <span class="count">(564)</span>
+                                        <span class="favorite"> Favorite Article </span> (
+                                        <span class="count">
+                                        </span>
+                                        )
                                     </button>
                                 </div>
                             </c:otherwise>
@@ -248,11 +369,13 @@
                 <div class="col-8">
                     <div class="card comment-form">
                         <div class="card-block">
-                            <textarea id="comment-input" class="form-control" placeholder="Write a comment..." rows="3"></textarea>
+                            <textarea id="comment-input" class="form-control"
+                                placeholder="Write a comment..." rows="3"></textarea>
                         </div>
                         <div class="card-footer">
                             <img class="comment-img" src="/resources/images/avatar.png" alt="">
-                            <button class="btn btn-card btn-sm" onclick="postComments()">Post Comment</button>
+                            <button class="btn btn-card btn-sm" onclick="postComments()">Post
+                                Comment</button>
                         </div>
                     </div>
                     <div class="comments">
@@ -263,4 +386,5 @@
         </div>
     </div>
 </body>
+
 </html>

+ 3 - 2
realworld/src/main/webapp/WEB-INF/jsp/include/header.jsp

@@ -69,9 +69,10 @@
                     </a>
                 </li>
                 <li class="nav-item">
+                    <c:set var="activeCondition" value="/user/${ssId}" />
                     <a
-                        href="...userpage.html"
-                        class="link <c:if test="${ssPath eq '/...'}">active</c:if>"
+                        href="/user/${ssId}"
+                        class="link <c:if test="${ssPath eq activeCondition}">active</c:if>"
                     >
                         <img src="/resources/images/avatar.png" alt=""> 
                         <c:out value="${ssUsername}"></c:out>님

+ 77 - 57
realworld/src/main/webapp/WEB-INF/jsp/main.jsp

@@ -26,6 +26,14 @@
             .join('&');
 
     let url = '/article/page?' + query;
+    
+    function selectFeed() {
+        if ("${ssIsLogin}" === 'true') {
+            return 'your-feed'
+        } else {
+            return 'global-feed';
+        }
+    }
 
     // 1. feed를 바꾼 경우
     // 2. 태그를 클릭한 경우
@@ -47,7 +55,7 @@
             } else if (clickedTag !== undefined) { // 태그가 클릭된 경우
                 const tags = article.querySelectorAll('.tag');
                 const tagSet = new Set(Array.from(tags).map(tag => tag.textContent));
-
+                
                 if (targetFeed.id === 'global-feed' && tagSet.has(clickedTag)) {
                     display = 'block';
                 }
@@ -96,21 +104,21 @@
         // targetFeed 초기화
         targetFeed = unFocusedFeed;
         setLoading('on');
+
+        // 태그와 게시글 초기화
         tagArray = [];
         articleList = document.querySelector('#article-list');
-  
         while(articleList.firstChild) {
             articleList.firstChild.remove();
         }
 
         params['articleId'] = -1;
-        params['feed'] = unFocusedFeed.id;
+        params['feed'] = targetFeed.id;
 
         query = Object.keys(params)
             .map(key => encodeURIComponent(key) + '=' + encodeURIComponent(params[key]))
             .join('&');
-
-        let url = '/article/page?' + query;
+        url = '/article/page?' + query;
 
         fetch(url, options)
             .then(response => response.json())
@@ -128,61 +136,68 @@
         }, 2000);
     }
 
-    
+    // 좋아요 버튼
     function favoriteBtn(indexNumber) {
         const favoriteNum = event.target.querySelector('.count').textContent;
+        const favorite = event.target.querySelector('.favorite-btn');
         let counts = parseInt(favoriteNum);
         let newCounts = counts + 1;
+        console.log(event.target);
         
-        const favBtn = document.querySelectorAll('.favorite-btn');
+        // 로그인한 경우에만 실행
         if ("${ssIsLogin}" === "true") {
-            favBtn.forEach(value => {
-                if (value.classList.contains(indexNumber)) {
-                    if(event.target.classList.contains('active')){
-                        event.target.querySelector('.count').textContent = counts - 1;
-                        fetch('/article/' + indexNumber + '/favorite', { method: 'DELETE' })
-                            .then(response => {
-                                if(response.status === 200) {
-                                return value.classList.remove('active');
-                                }
-                            });
-                    } else {
-                        event.target.querySelector('.count').textContent = newCounts;
-                        fetch('/article/'+ indexNumber + '/favorite', {
-                            body: JSON.stringify({
-                                articleId: indexNumber,
-                                userId: "${ssId}",
-                                created: new Date()
-                            }),
-                            method: 'POST',
-                            headers: {
-                                "Content-Type": "application/json"
-                            }
-                        })
-                        .then(response => {
-                            if(response.status === 201){
-                                return value.classList.add('active');
-                                }
-                            })
+            if (event.target.classList.contains('active')) { // 이미 좋아요 버튼을 클릭한 경우
+                event.target.querySelector('.count').textContent = counts - 1;
+                event.target.classList.remove('active');
+
+                fetch('/article/' + indexNumber + '/favorite', { method: 'DELETE' })
+                    .then(response => {
+                        if (response.status === 200) {
+                            return ;
+                        }
+                    });
+            } else { // 좋아요 버튼을 클릭하지 않을 경우
+                event.target.querySelector('.count').textContent = newCounts;
+                event.target.classList.add('active')
+
+                fetch('/article/' + indexNumber + '/favorite', {
+                    body: JSON.stringify({
+                        articleId: indexNumber,
+                        userId: "${ssId}",
+                        created: new Date()
+                    }),
+                    method: 'POST',
+                    headers: {
+                        "Content-Type": "application/json"
                     }
-                }
-            });
+                })
+                    .then(response => {
+                        if (response.status === 201) {
+                            return ;
+                        }
+                    })
+            }
+            
         } else {
             location.href = "/user/signin"
         }
     }
 
+    // 태그 정렬 https://curryyou.tistory.com/229
     function getSortedArr(array) {
+        // 출현 빈도 구하기
         const counts = array.reduce((pv, cv) => {
             pv[cv] = (pv[cv] || 0) + 1;
             return pv;
         }, {});
 
+        // 배열 생성 => [ [ key: 개수 ], [ key: 개수 ], ...]
         const result = [];
         for (let key in counts) {
             result.push([key, counts[key]]);
         };
 
+        // 빈도별로 정렬
         result.sort((first, second) => {
             return second[1] - first[1];
         });
@@ -190,28 +205,23 @@
         return result.slice(0, 10);
     }
 
+    // 클릭한 태그 표시
     function tagFilter(event) {
         let currentValue = document.querySelector('.tag.active');
+
         if (currentValue !== null) {
-            currentValue.classList.remove('active');
+            currentValue.classList.remove('active');       
         }
         event.target.classList.add('active');
     }
 
-    function selectFeed() {
-            if ("${ssIsLogin}" === 'true') {
-                return 'your-feed'
-            } else {
-                return 'global-feed';
-            }
-    }
-
-    //게시글 표시
+    // 게시글 및 태그 표시
     function loadArticle(articles) { 
         articles.forEach(article => {
             console.log(JSON.stringify(article))
             const domParser = new DOMParser();
-
+            
+            // 게시글 정보 표시
             const domStrArticleMeta =
                 `
                 <div class="article-meta">
@@ -235,11 +245,12 @@
             const divArticleMeta = domParser.parseFromString(domStrArticleMeta, 'text/html').body.firstChild;
             divArticleMeta.querySelector('.name').textContent = article.writerName;
             divArticleMeta.querySelector('.date').textContent = new Date(article.created).toLocaleString();
-            console.log(article.favorite)
+            
             if(article.favorite) {
                 divArticleMeta.querySelector('.favorite-btn').classList.add('active');
             }
 
+            // 게시글 내용 및 게시글태그 표시
             const domStrPreviewLink =
                 `
                 <a href="/article/\${article.id}" class="preview-link">
@@ -283,9 +294,8 @@
 
             articleList.appendChild(articlePreview);
 
-            //태그 배열 생성
-            let tagsArray = article.tags.split(',')
-            
+            // 태그 배열 생성
+            let tagsArray = article.tags.split(',')          
             const tagList = document.querySelector('#tag-list');
             
             while(tagList.firstChild) {
@@ -300,7 +310,7 @@
             })
         });
 
-        //태그 정렬
+        // 태그 표시
         const tags = Array.from(tagSet);
         const tagList = window.document.querySelector('#tag-list');
 
@@ -313,6 +323,11 @@
             a.classList.add("tag");
             a.textContent = `\${tagValue} (\${tagCount})`
             a.onclick = (event) => {
+                // 해당 태그의 토글버튼 생성
+                const tagToggle = document.querySelector('#tag-feed');
+                tagToggle.textContent = tagValue;
+                
+                // 해당 태그의 게시물 표시
                 filterArticle({ 'clickedTag': tagValue });
             }
             tagList.appendChild(a);
@@ -326,18 +341,21 @@
             const articleList = document.querySelector('#article-list');
             const loading = document.createElement('div');
 
-            moreButton.textContent = 'More';
+            moreButton.textContent = '더보기';
             moreButton.classList.add('more-button');
             loading.setAttribute('id', 'loading');
             articleList.appendChild(moreButton);
+
+            // more 버튼 클릭 시 다음페이지 게시물을 가져온다
             moreButton.onclick = () => {
                 articleList.removeChild(moreButton);
                 articleList.appendChild(loading);
+
+                // 마지막 게시글의 id
                 params['articleId'] = articles[articles.length - 1].id;
                 query = Object.keys(params)
                         .map(key => encodeURIComponent(key) + '=' + encodeURIComponent(params[key]))
                         .join('&');
-
                 url = '/article/page?' + query;
 
                 fetch(url, options)
@@ -345,9 +363,8 @@
                     .then(json => {
                         const { articles, paging } = json;
                         username = "${ssUsername}";
-                        loadArticle(articles);
-                        // articleList.removeChild(moreButton);
                         articleList.removeChild(loading);
+                        loadArticle(articles);
                         nextPageLoad(articles, paging);
                         
                     })
@@ -443,6 +460,9 @@
                                 <a href="javascript:void(0);" onclick="focusFeed()">Global
                                     Feed</a>
                             </li>
+                            <li id="tag-feed" class="nav-item">
+                                <a href="javascript:void(0);"></a>
+                            </li>
                         </ul>
                     </div>
 

+ 20 - 1
realworld/src/main/webapp/WEB-INF/jsp/user/settings.jsp

@@ -54,7 +54,26 @@ const submitForm = () => {
         })
 }
 
+// 입력폼 벨리데이션
+const validateForm = () => {
+    const articleTitle = editForm['article-title'].value;
+    if (articleTitle === null || articleTitle === "") {
+        alert("제목을 입력해주세요.");
+        return false;
+    }
+    const subtitle = editForm['subtitle'].value;
+    if (subtitle === null || subtitle === "") {
+        alert("부제목을 입력해주세요.");
+        return false;
+    }
+    const articleBody = editForm['article-body'].value;
+    if (articleBody === null || articleBody === "") {
+        alert("내용을 입력해주세요.");
+        return false;
+    }
 
+    return true;
+}
 
 
 </script>
@@ -120,7 +139,7 @@ const submitForm = () => {
                     <button class="btn-settings" onclick="submitForm()">Update Settings</button>
                 </div>
                 <hr>
-                <button type="button" class="btn-logout" onclick="location.href='/'">Or click here to logout.</button>
+                <button type="button" class="btn-logout" onclick="location.href='/user/logout'">Or click here to logout.</button>
             </form>
         </div>
     </div>

+ 150 - 0
realworld/src/main/webapp/WEB-INF/jsp/user/userDetail.jsp

@@ -0,0 +1,150 @@
+<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
+<%@ taglib uri="http://www.springframework.org/tags/form" prefix="form" %>
+<%@ taglib uri="http://java.sun.com/jsp/jstl/fmt" prefix="fmt" %>
+<%@ taglib uri="http://www.springframework.org/tags" prefix="spring" %>
+<%@ taglib uri="http://java.sun.com/jsp/jstl/functions" prefix="fn" %>
+<%@ page language="java" contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
+
+<jsp:include page="/WEB-INF/jsp/include/head.jsp"></jsp:include>
+<script>
+
+
+</script>
+</head>
+
+<body>
+    <jsp:include page="/WEB-INF/jsp/include/header.jsp"></jsp:include>
+    <!-- user-page content -->
+
+    ${myArticles}
+    ${paging}
+    ${user}
+
+    <div class="user-page">
+        <!-- User Info -->
+        <section class="user-info">
+            <div class="container">
+                <div class="row">
+                    <div class="col-10">
+                        <img src="img/avatar.jpg" alt="" class="user-img">
+                        <h4 class="user-name">username</h4>
+                        <p class="profile-bio">short bio</p>
+                        <a href="settings.html" class="action-btn btn-sm">
+                            <i class="fas fa-cog"></i> Edit Profile Settings
+                        </a>
+                    </div>
+                </div>
+            </div>
+        </section>
+        <!-- Body -->
+        <div class="container main">
+            <div class="row">
+                <div class="col-10">
+                    <!-- 토글 버튼으로 피드 내용 보기 -->
+                    <div class="toggle">
+                        <ul class="nav">
+                            <li class="nav-item active">
+                                <a href="#">My Articles</a>
+                            </li>
+                            <li class="nav-item">
+                                <a href="userpage-favorited.html">Favorited Articles</a>
+                            </li>
+                        </ul>
+                    </div>
+                    <div class="article">
+                        <article class="article-preview">
+                            <div class="article-meta">
+                                <div class="metadata">
+                                    <a href="#" class="profile-link">
+                                        <img src="img/avatar.jpg" alt="">
+                                    </a>
+                                    <div class="article-info">
+                                        <a href="#" class="name">username</a>
+                                        <span class=date>November 24, 2021</span>
+                                    </div>
+                                </div>
+                                <div>
+                                    <button class="favorite-btn">
+                                        <i class="fas fa-heart"></i>
+                                        <span class="count">564</span>
+                                    </button>
+                                </div>
+                            </div>
+                            <a href="article-my.html" class="preview-link">
+                                <h1 class="preview-title">Create a new implementation</h1>
+                                <p>join the community by creating a new implementation</p>
+                                <div class="tag-data">
+                                    <span>Read more...</span>
+                                    <ul class="tag-list">
+                                        <li class="tag">implementations</li>
+                                        <li class="tag">implementations</li>
+                                    </ul>
+                                </div>
+                            </a>
+                        </article>
+                        <article class="article-preview">
+                            <div class="article-meta">
+                                <div class="metadata">
+                                    <a href="#" class="profile-link">
+                                        <img src="img/avatar.jpg" alt="">
+                                    </a>
+                                    <div class="article-info">
+                                        <a href="#" class="name">username</a>
+                                        <span class=date>November 24, 2021</span>
+                                    </div>
+                                </div>
+                                <div>
+                                    <button class="favorite-btn">
+                                        <i class="fas fa-heart"></i>
+                                        <span class="count">564</span>
+                                    </button>
+                                </div>
+                            </div>
+                            <a href="article-my.html" class="preview-link">
+                                <h1 class="preview-title">Create a new implementation</h1>
+                                <p>join the community by creating a new implementation</p>
+                                <div class="tag-data">
+                                    <span>Read more...</span>
+                                    <ul class="tag-list">
+                                        <li class="tag">implementations</li>
+                                    </ul>
+                                </div>
+                            </a>
+                        </article>
+                        <article class="article-preview">
+                            <div class="article-meta">
+                                <div class="metadata">
+                                    <a href="#" class="profile-link">
+                                        <img src="img/avatar.jpg" alt="">
+                                    </a>
+                                    <div class="article-info">
+                                        <a href="#" class="name">username</a>
+                                        <span class=date>November 24, 2021</span>
+                                    </div>
+                                </div>
+                                <div>
+                                    <button class="favorite-btn">
+                                        <i class="fas fa-heart"></i>
+                                        <span class="count">564</span>
+                                    </button>
+                                </div>
+                            </div>
+                            <a href="article-my.html" class="preview-link">
+                                <h1 class="preview-title">Create a new implementation</h1>
+                                <p>join the community by creating a new implementation</p>
+                                <div class="tag-data">
+                                    <span>Read more...</span>
+                                    <ul class="tag-list">
+                                        <li class="tag">implementations</li>
+                                    </ul>
+                                </div>
+                            </a>
+                        </article>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</body>
+
+</html>

+ 18 - 7
realworld/src/main/webapp/resources/css/style.css

@@ -216,6 +216,7 @@ button {
     float: left;
     padding: 0.5rem 1rem;
     margin-bottom: -1px;
+    color: var(--light-grey-color);
 }
 
 .toggle .nav .nav-item a {
@@ -384,18 +385,23 @@ img {
     background-color: #646d74;
 }
 
+.home-page .aside .tag-list .tag.active {
+    text-decoration: underline;
+}
+
 .home-page #article-list .more-button {
     float: right;
     margin-top: 0.6rem;
     margin-bottom: 1.2rem;
-    border: 1px solid #818a92;
-    padding: 0.25rem 0.5rem;
-    border-radius: 3px;
+    border-bottom: 1px solid #818a92;
+    padding: 0.1rem 0;
+    font-size: 1.2rem;
+    font-weight: 600;
 }
 
-.home-page #article-list .more-button:hover {
+/* .home-page #article-list .more-button:hover {
     background-color: #dddddd;
-}
+} */
 
 #loading {
     display: inline-block;
@@ -574,6 +580,11 @@ textarea {
     filter: none;
 }
 
+.article-page button i, 
+.article-page button span {
+    pointer-events: none;
+}
+
 .article-page .edit-article,
 .article-page .follow-btn {
     color: var(--light-grey-color);
@@ -846,7 +857,7 @@ hr {
 .modal-message {
     padding-bottom: 40px;
     font-size: 2rem;
-    font-family: 'Nanum Gothic', sans-serif;
+    font-family: 'Noto Sans KR', sans-serif;
     color: var(--green-color);
 }
 
@@ -855,7 +866,7 @@ hr {
     background-color: var(--green-color);
     color: var(--white-color);
     font-weight: 700;
-    font-family: 'Nanum Gothic', sans-serif;
+    font-family: 'Noto Sans KR', sans-serif;
     width: 5rem;
     height: 2rem;
     border-radius: 0.3rem;