#### 요구사항

- 기능 요구사항
__기본기능__
 v 권한 구분 관리자 권한 구현
 v 깃헙아이디, 구글아이디로 로그인 가능
 v 블로그 기본 CRUD
 v 연관글 등록
 v 이미지 삽입시 이미지서버에 선업로드 후 URL반환
 v 댓글과 대댓글 구현
 v  랜드화면은 무한스크롤 구현
 v 카테고리 화면에서는 일반 페이징
 v 토스트 에디터 사용
 v 게시물 조회수 순위별
 v 최근 게시물
 v 최근 코멘트 노출
 v 블로그 태그별 검색과 태그 보이기
 v 일반 검색기능
 v 썸네일 링크로 추가
-----11.18
 - 카테고리 목록 설정

__추가기능__
 - TOC
 - 글 1분단위 자동저장
 - 새로운글 알람 보내기
 - RSS피드
 - 공유하기 기능
 - 글 포스팅시 자동 커밋 푸시
This commit is contained in:
jinia91
2021-11-22 00:35:28 +09:00
parent 9ab902808b
commit 026fe79dfb
21 changed files with 152 additions and 75 deletions

View File

@@ -8,8 +8,6 @@ import myblog.blog.article.service.ArticleService;
import myblog.blog.category.dto.CategoryForView; import myblog.blog.category.dto.CategoryForView;
import myblog.blog.category.dto.CategoryNormalDto; import myblog.blog.category.dto.CategoryNormalDto;
import myblog.blog.category.service.CategoryService; import myblog.blog.category.service.CategoryService;
import myblog.blog.comment.domain.Comment;
import myblog.blog.comment.dto.CommentDto;
import myblog.blog.comment.dto.CommentDtoForSide; import myblog.blog.comment.dto.CommentDtoForSide;
import myblog.blog.comment.service.CommentService; import myblog.blog.comment.service.CommentService;
import myblog.blog.member.auth.PrincipalDetails; import myblog.blog.member.auth.PrincipalDetails;

View File

@@ -80,8 +80,4 @@ public class Article extends BasicEntity {
this.category = category; this.category = category;
} }
public void deleteArticle(){
this.articleTagLists = null;
this.parentCommentList = null;
}
} }

View File

@@ -4,6 +4,7 @@ import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotBlank;
import java.util.Objects;
@Setter @Setter
@Getter @Getter

View File

@@ -7,24 +7,27 @@ import org.springframework.stereotype.Component;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.function.Supplier; import java.util.function.Function;
@Component @Component
public class UserInfoFactory { public class UserInfoFactory {
private final static Map<String, Function<OAuth2User, Oauth2UserInfo>> userInfoFactoryMap;
static {
userInfoFactoryMap = new HashMap<>();
userInfoFactoryMap.put("google", GoogleUserInfo::new);
userInfoFactoryMap.put("facebook", FacebookUserInfo::new);
userInfoFactoryMap.put("kakao", FacebookUserInfo::new);
userInfoFactoryMap.put("naver", FacebookUserInfo::new);
}
public Oauth2UserInfo makeOauth2UserinfoOf(OAuth2UserRequest oAuth2UserRequest, OAuth2User oAuth2User) { public Oauth2UserInfo makeOauth2UserinfoOf(OAuth2UserRequest oAuth2UserRequest, OAuth2User oAuth2User) {
if (oAuth2UserRequest.getClientRegistration().getRegistrationId().equals("google")) { return userInfoFactoryMap
return new GoogleUserInfo(oAuth2User.getAttributes()); .get(oAuth2UserRequest.getClientRegistration().getRegistrationId())
} else if (oAuth2UserRequest.getClientRegistration().getRegistrationId().equals("facebook")) { .apply(oAuth2User);
return new FacebookUserInfo(oAuth2User.getAttributes());
} else if (oAuth2UserRequest.getClientRegistration().getRegistrationId().equals("kakao")) {
return new KakaoUserInfo(oAuth2User.getAttributes());
} else if (oAuth2UserRequest.getClientRegistration().getRegistrationId().equals("naver")) {
return new NaverUserInfo(oAuth2User.getAttribute("response"));
}
else {
throw new IllegalArgumentException("지원하지 않는 Oauth 인증 시도입니다");}
} }

View File

@@ -1,5 +1,6 @@
package myblog.blog.member.auth.userinfo; package myblog.blog.member.auth.userinfo;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Map; import java.util.Map;
@@ -9,8 +10,8 @@ public class FacebookUserInfo implements Oauth2UserInfo {
private Map<String, Object> attributes; private Map<String, Object> attributes;
public FacebookUserInfo(Map<String, Object> attributes) { public FacebookUserInfo(OAuth2User oAuth2User) {
this.attributes = attributes; this.attributes = oAuth2User.getAttributes();
} }
@Override @Override

View File

@@ -1,5 +1,6 @@
package myblog.blog.member.auth.userinfo; package myblog.blog.member.auth.userinfo;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Map; import java.util.Map;
@@ -9,8 +10,8 @@ public class GoogleUserInfo implements Oauth2UserInfo{
private Map<String,Object> attributes; private Map<String,Object> attributes;
public GoogleUserInfo(Map<String, Object> attributes) { public GoogleUserInfo(OAuth2User oAuth2User) {
this.attributes = attributes; this.attributes = oAuth2User.getAttributes();
} }

View File

@@ -1,5 +1,6 @@
package myblog.blog.member.auth.userinfo; package myblog.blog.member.auth.userinfo;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Map; import java.util.Map;
@@ -9,8 +10,8 @@ public class KakaoUserInfo implements Oauth2UserInfo {
private Map<String, Object> attributes; private Map<String, Object> attributes;
public KakaoUserInfo(Map<String, Object> attributes) { public KakaoUserInfo(OAuth2User oAuth2User) {
this.attributes = attributes; this.attributes = oAuth2User.getAttributes();
} }
@Override @Override

View File

@@ -1,5 +1,6 @@
package myblog.blog.member.auth.userinfo; package myblog.blog.member.auth.userinfo;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Map; import java.util.Map;
@@ -9,8 +10,8 @@ public class NaverUserInfo implements Oauth2UserInfo {
private Map<String, Object> attributes; private Map<String, Object> attributes;
public NaverUserInfo(Map<String, Object> attributes) { public NaverUserInfo(OAuth2User oAuth2User) {
this.attributes = attributes; this.attributes = oAuth2User.getAttribute("response");
} }
@Override @Override

View File

@@ -16,4 +16,5 @@ public interface Oauth2UserInfo {
Map<String, Object> getAttributes(); Map<String, Object> getAttributes();
} }

View File

@@ -1,29 +0,0 @@
package myblog.blog.member.doamin;
import lombok.Getter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import java.time.LocalDateTime;
@Table(name = "persistent_logins")
@Entity
@Getter
public class PersistentLogins {
@Id
@Column(length = 64)
private String series;
@Column(nullable = false, length = 64)
private String username;
@Column(nullable = false, length = 64)
private String token;
@Column(name = "last_used", nullable = false, length = 64)
private LocalDateTime lastUsed;
}

View File

@@ -1,8 +1,14 @@
const myModal = new bootstrap.Modal(document.getElementById('thumbnailModal'), {keyboard: false});
const thumbBox = document.getElementById("thumbBox"); const thumbBox = document.getElementById("thumbBox");
const uploadThumbBtn = document.getElementById("thumbnail"); const uploadThumbBtn = document.getElementById("thumbnail");
const thumbDel = document.getElementById("thumbDelBtn"); const thumbDel = document.getElementById("thumbDelBtn");
const previewThumb = document.getElementById("thumbnailPreView"); const previewThumb = document.getElementById("thumbnailPreView");
const thumbUrl = document.getElementById("thumbnailUrl") const thumbUrl = document.getElementById("thumbnailUrl");
const thumbUrlUploadBtn = document.getElementById("thumbnail-url-upload-btn");
function uploadImg(input) { function uploadImg(input) {
@@ -21,13 +27,17 @@ function uploadImg(input) {
if (xhr.readyState === 4 && xhr.status === 200) { if (xhr.readyState === 4 && xhr.status === 200) {
thumbUrl.value = xhr.response; thumbUrl.value = xhr.response;
previewThumb.src = thumbUrl.value;
// 썸네일 등록은 서버에서 하도록 리팩토링할것
// const reader = new FileReader();
// reader.onload = e => {
// previewThumb.src = e.target.result;
// }
// reader.readAsDataURL(input.files[0])
const reader = new FileReader();
reader.onload = e => {
previewThumb.src = e.target.result;
}
reader.readAsDataURL(input.files[0])
thumbBox.style.display = '' thumbBox.style.display = ''
myModal.hide();
} else { } else {
alert("이미지가 정상적으로 업로드되지 못했습니다.") alert("이미지가 정상적으로 업로드되지 못했습니다.")
@@ -37,6 +47,16 @@ function uploadImg(input) {
} }
thumbUrlUploadBtn.addEventListener("click", () =>{
const thumbUrlUploadInput = document.getElementById("thumbnail-url-upload-input");
const url = thumbUrlUploadInput.value;
previewThumb.src = url;
thumbUrl.value = url;
thumbBox.style.display = ''
myModal.hide();
})
uploadThumbBtn.addEventListener("change", e => { uploadThumbBtn.addEventListener("change", e => {
uploadImg(e.target); uploadImg(e.target);
}) })

View File

@@ -62,15 +62,35 @@
<div class="container"> <div class="container">
<div class="row justify-content-center mt-5 mb-3 g-0"> <div class="row justify-content-center mt-5 mb-3 g-0">
<div class="d-flex"> <div class="d-flex flex-row-reverse">
<label class="btn btn-secondary me-auto" for="thumbnail">썸네일 파일</label>
<input type="file" id="thumbnail" name="thumbnail" accept="image/*" class="d-none"> <button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#thumbnailModal">
<!-- <div>--> 썸네일 등록
<!-- <input type="text" id="thumbnailUrlSource" name="thumbnail" accept="text">--> </button>
<!-- <label class="btn btn-secondary" for="thumbnailUrlSource">썸네일 URL</label>-->
<!-- </div>--> <div class="modal fade" id="thumbnailModal" tabindex="-1" aria-labelledby="thumbnailModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="thumbnailModalLabel">썸네일 등록</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-footer">
<input type="text" id="thumbnail-url-upload-input" class="form-control">
<button class="btn btn-secondary" id="thumbnail-url-upload-btn">URL 업로드</button>
<label class="btn btn-secondary" for="thumbnail">파일 업로드</label>
<input type="file" id="thumbnail" name="thumbnail" accept="image/*" class="d-none">
</div>
</div>
</div>
</div>
</div> </div>
<form class="" method="post" enctype="multipart/form-data" th:object="${articleDto}" <form class="" method="post" enctype="multipart/form-data" th:object="${articleDto}"
th:action="|@{/article/edit(articleId=${articleDto.getId()})}|" id="writeArticleForm"> th:action="|@{/article/edit(articleId=${articleDto.getId()})}|" id="writeArticleForm">
@@ -119,6 +139,7 @@
</div> </div>
<!--scripts--> <!--scripts-->
<script src="/node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="/node_modules/@yaireo/tagify/dist/tagify.min.js"></script> <script src="/node_modules/@yaireo/tagify/dist/tagify.min.js"></script>
<script th:replace="layout/fragments.html :: tag"></script> <script th:replace="layout/fragments.html :: tag"></script>
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script> <script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>

View File

@@ -134,6 +134,8 @@
</div> </div>
<!--page e--> <!--page e-->
<script src="/node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
</section> </section>
</body> </body>

View File

@@ -134,6 +134,8 @@
</div> </div>
<!--page e--> <!--page e-->
<script src="/node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
</section> </section>
</body> </body>

View File

@@ -134,6 +134,9 @@
</div> </div>
<!--page e--> <!--page e-->
<script src="/node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
</section> </section>
</body> </body>

View File

@@ -51,7 +51,7 @@
<div class="main "> <div class="main ">
<div class="carousel-inner "> <div class="carousel-inner ">
<div class="carousel-item active"> <div class="carousel-item active" style="cursor: default">
<img th:src="${article.getThumbnailUrl()}" class="w-100 vh-100 cover" <img th:src="${article.getThumbnailUrl()}" class="w-100 vh-100 cover"
alt="..."> alt="...">
<div class="card-img-overlay text-shadow text-white text-center row justify-content-center align-content-center p-0"> <div class="card-img-overlay text-shadow text-white text-center row justify-content-center align-content-center p-0">
@@ -61,7 +61,7 @@
</div> </div>
</div> </div>
<textarea name="contents" id="contents" th:text="${article.getContent()}" hidden></textarea> <textarea name="contents" id="contents" hidden></textarea>
<div class="mt-5 ms-2 me-2 ms-sm-5 me-sm-5 mt-sm-5 d-flex justify-content-center"> <div class="mt-5 ms-2 me-2 ms-sm-5 me-sm-5 mt-sm-5 d-flex justify-content-center">
@@ -140,11 +140,11 @@
<!-- comment e --> <!-- comment e -->
<script src="/node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script> <script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
<script src="/js/getCsrf.js"></script> <script src="/js/getCsrf.js"></script>
<script src="/js/articleView.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.0/moment.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.0/moment.min.js"></script>
<script th:replace="layout/fragments.html :: view"></script>
<script th:replace="layout/fragments.html :: comment"></script> <script th:replace="layout/fragments.html :: comment"></script>
</div> </div>
</section> </section>

View File

@@ -62,10 +62,32 @@
<div class="row justify-content-center mt-5 mb-3 g-0"> <div class="row justify-content-center mt-5 mb-3 g-0">
<div class="d-flex flex-row-reverse"> <div class="d-flex flex-row-reverse">
<label class="btn btn-secondary" for="thumbnail">썸네일 등록</label>
<input type="file" id="thumbnail" name="thumbnail" accept="image/*" class="d-none"> <button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#thumbnailModal">
썸네일 등록
</button>
<div class="modal fade" id="thumbnailModal" tabindex="-1" aria-labelledby="thumbnailModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="thumbnailModalLabel">썸네일 등록</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-footer">
<input type="text" id="thumbnail-url-upload-input" class="form-control">
<button class="btn btn-secondary" id="thumbnail-url-upload-btn">URL 업로드</button>
<label class="btn btn-secondary" for="thumbnail">파일 업로드</label>
<input type="file" id="thumbnail" name="thumbnail" accept="image/*" class="d-none">
</div>
</div>
</div>
</div>
</div> </div>
<form class="" method="post" enctype="multipart/form-data" th:object="${articleDto}" <form class="" method="post" enctype="multipart/form-data" th:object="${articleDto}"
th:action="@{/article/write}" id="writeArticleForm"> th:action="@{/article/write}" id="writeArticleForm">
@@ -115,6 +137,7 @@
whitelist.push(tag.name) whitelist.push(tag.name)
} }
</script> </script>
<script src="/node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="/node_modules/@yaireo/tagify/dist/tagify.min.js"></script> <script src="/node_modules/@yaireo/tagify/dist/tagify.min.js"></script>
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script> <script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
<script src="/js/getCsrf.js"></script> <script src="/js/getCsrf.js"></script>

View File

@@ -129,6 +129,7 @@
<script src="/node_modules/wow.js/dist/wow.js"></script> <script src="/node_modules/wow.js/dist/wow.js"></script>
<script src="/js/infinityScroll.js"></script> <script src="/js/infinityScroll.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.0/moment.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.0/moment.min.js"></script>
<script src="/node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
</section> </section>
</body> </body>

View File

@@ -3,6 +3,36 @@
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5"> xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<script th:fragment="view" th:inline="javascript" type="application/javascript">
const viewer = toastui.Editor.factory({
el: document.querySelector('#viewer'),
viewer: true,
height: '500px',
initialValue: ''
});
viewer.setMarkdown([[${article.getContent()}]]);
function deleteArticle() {
if (confirm("글을 정말 삭제하시겠습니까?") == true) {
document.getElementById("deleteArticle").submit();
} else {
return false;
}
}
</script>
<script th:fragment="comment" th:inline="javascript" type="application/javascript"> <script th:fragment="comment" th:inline="javascript" type="application/javascript">
const replyBox = document.getElementById("commentBox"); const replyBox = document.getElementById("commentBox");

View File

@@ -67,7 +67,6 @@
</button> </button>
<section th:replace="${content}"></section> <section th:replace="${content}"></section>
<script src="/node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="/js/arrow.js"></script> <script src="/js/arrow.js"></script>
<script src="/js/search.js"></script> <script src="/js/search.js"></script>
<!-- scripts e --> <!-- scripts e -->

View File

@@ -84,6 +84,8 @@
<div style="margin-bottom: 100px"></div> <div style="margin-bottom: 100px"></div>
<script src="/node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
</section> </section>
</body> </body>