Compare commits

54 Commits

Author SHA1 Message Date
minseokkang
da13e49d62 Merge remote-tracking branch 'origin/master' 2022-12-08 17:07:08 +09:00
minseokkang
b47b947e9d fix: User Repository, Service Refactoring. 2022-12-08 17:06:53 +09:00
minseokkang
5091f21a16 Merge remote-tracking branch 'origin/master' 2022-12-07 16:12:30 +09:00
minseokkang
8e25130109 fix : UserController, User DTO Refactoring 2022-12-07 16:12:13 +09:00
minseokkang
22a42d0920 Update README.md 2022-12-06 01:06:20 +09:00
minseokkang
e1f0ccfd9c Update README.md 2022-12-06 01:05:49 +09:00
minseokkang
c897d26762 Update README.md 2022-12-06 01:00:06 +09:00
kms
e6b11d6d0f feat: [FE] Comment API Add. 2022-12-06 00:43:01 +09:00
minseokkang
427eae6efd feat : [FE] Article Update ADD. 2022-12-05 19:01:06 +09:00
kms
74451508e9 feat : Pagination Implement. 2022-12-04 19:31:37 +09:00
kms
20487f5a46 feat : Home Article ReRender Success and Pagination Template add 2022-12-04 18:12:53 +09:00
kms
828c744285 save pagination 2022-12-04 15:22:36 +09:00
minseokkang
b4cf76689c 12.02 temp save 2022-12-02 18:00:51 +09:00
minseokkang
624c509a41 12.02 temporary save in company 2022-12-02 12:57:59 +09:00
kms
5a40832408 fix : API service refactoring 2022-12-02 01:02:25 +09:00
minseokkang
b8caa0140b fix : addComment function 2022-12-01 16:27:34 +09:00
minseokkang
ebf851d1c5 fix : ArticleDetail Page Refactoring 2022-12-01 16:20:35 +09:00
minseokkang
a55f0c28f8 fix : setting page refactoring complete 2022-12-01 10:13:27 +09:00
kms
aeb52fa2f4 fix : refactoring code, disunite API service and async function add. TODO watch Token statue because async state not recommend. 2022-12-01 01:08:05 +09:00
minseokkang
ce1e1ca573 feat : comment post add and profile connect.
TODO remove, update comment, redirect comment status
2022-11-30 17:35:39 +09:00
minseokkang
5f4bdf13ea feat: favorite, unfavorite Article Function add 2022-11-30 13:22:44 +09:00
minseokkang
eaa50e298b fix : test Code 2022-11-29 17:01:29 +09:00
minseokkang
8e8a0b9c48 feat : common date util function add. convert to Us-en String from LocalDateTime 2022-11-29 15:21:47 +09:00
kms
c51f469124 fix : feed API, TODO FIX Testcode. 2022-11-29 00:53:04 +09:00
minseokkang
2248112454 feat : Article Detail Follow, Unfollow user Function Implement. 2022-11-28 12:43:05 +09:00
kms
4fc89b0e5b feat: add Article Create Author Profile get Method. 2022-11-27 18:55:26 +09:00
kms
311ab00234 fix : change Spring WebConfig 2022-11-27 18:07:10 +09:00
kms
e8247f7521 feat: header click active event implement. 2022-11-27 16:35:04 +09:00
kms
021c8d7b74 feat: add ListTag Component. 2022-11-27 16:14:33 +09:00
kms
534f62c4e8 fet : routing home page articles author name click -> Profile page 2022-11-27 15:47:51 +09:00
kms
ea3ab8bc3a fix : refactoring 2022-11-26 23:17:56 +09:00
minseokkang
3d212c0302 fix : home articles view change 2022-11-25 12:34:03 +09:00
kms
020c59d380 feat : Your Feed API Implement. 2022-11-25 01:05:16 +09:00
minseokkang
a1c45d5a82 11.24 save 2022-11-24 18:05:56 +09:00
kms
8f30ee0a19 feat : render articles 2022-11-23 23:04:47 +09:00
minseokkang
59d0f6c97d 11.23 save success complicated json response in vue.js 2022-11-23 18:15:35 +09:00
minseokkang
a709acac4b 11.22 save TODO foreach print articles data 2022-11-22 18:08:13 +09:00
minseokkang
cf9edf2cfe feat: [FE] create ArticleImplement. and sample Article Page create. and add BootStrap Css files 2022-11-22 12:40:41 +09:00
minseokkang
76b3121b67 11.21 save Problem List Post.. 2022-11-21 18:01:04 +09:00
kms
df3c2e19af 11.20 save add new Article API Implement... 2022-11-20 17:55:16 +09:00
kms
918b6c8cb2 fix : Login Test-Wrong Password input test Implement. fix other logics 2022-11-20 16:40:01 +09:00
kms
99dd1d292f fix : header update and Login Password Wrong Logic add 2022-11-20 16:28:40 +09:00
minseokkang
9471351943 11.18save TODO reload Header Login status 2022-11-18 17:58:59 +09:00
minseokkang
ebd42df504 feat: [FE] Profile and Settings connection, Implement Unfollow, follow API, getProfile. 2022-11-18 15:35:06 +09:00
minseokkang
2855b56749 fix : Login page, Register Page apply vuex 2022-11-18 10:34:50 +09:00
kms
f25e02777f fix : vuex ADD and signup test 2022-11-18 00:29:44 +09:00
minseokkang
81655e7509 update readme add Code Coverage 2022-11-17 18:11:35 +09:00
minseokkang
ff9b481a82 feat : jacoco code Coverage ADD 2022-11-17 18:09:29 +09:00
minseokkang
cfe4e8b0dd feat : [FE] UserUpdate API Implement. 2022-11-17 16:25:04 +09:00
kms
8c78ad0bdf fix : deploy bash script fix 2022-11-17 01:05:48 +09:00
kms
176e588ad0 fix : gitAction add **working Directory** 2022-11-16 22:41:08 +09:00
kms
9815200315 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	.github/workflows/autoTest.yml
2022-11-16 22:40:07 +09:00
kms
0909eab9c1 fix : gitAction add **working Directory** 2022-11-16 22:39:35 +09:00
kms
918a6a2b9e fix : gitAction add **working Directory** 2022-11-16 22:37:14 +09:00
53 changed files with 2919 additions and 233 deletions

View File

@@ -7,6 +7,9 @@ on:
jobs:
real-world:
runs-on: ubuntu-latest
env:
working-directory: .
steps:
- uses: actions/checkout@v3

View File

@@ -11,12 +11,15 @@ We've gone to great lengths to adhere to the **[Spring Boot]** community stylegu
For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo.
-----
## Enter
you can See Vue.js + SpringBoot FullStack Web Site Demo
<http://3.35.44.58:8080/>
------
## BackEnd - Spring
@@ -24,3 +27,39 @@ you can See Vue.js + SpringBoot FullStack Web Site Demo
<img width="464" alt="image" src="https://user-images.githubusercontent.com/30401054/201084053-60be024d-0615-40e1-9234-ceb926f402e5.png">
-----
### `Jacoco` Code Coverage (2022.11.17)
![image](https://user-images.githubusercontent.com/30401054/202404202-4c0879b8-a859-4f6a-b8d5-ccf28eef3fd9.png)
#### **Total 82% Code Coverage**
-----
# How it works
- Spring Boot(Java)
- JPA
- Security
- H2
- Vue3
- Vite
- vuerouter
- vuex
- localStorage
# Getting started
## Run Local
```shell
./gradlew bootRun
```
# FrontEnd
**I don't know much about the front end. I wanted to create a visible application, so I adopted and implemented that framework.**
But my codes are simple codes that even beginners can easily see.

View File

@@ -2,6 +2,7 @@ plugins {
id 'org.springframework.boot' version '2.7.3'
id 'io.spring.dependency-management' version '1.0.13.RELEASE'
id 'java'
id 'jacoco'
id 'com.github.node-gradle.node' version '3.5.0'
}
@@ -86,6 +87,27 @@ dependencies {
testImplementation 'org.springframework.security:spring-security-test'
}
jacoco {
toolVersion = "0.8.8"
reportsDir = file("$buildDir/customJacocoReportDir")
}
jacocoTestReport {
reports {
xml.enabled false
csv.enabled false
html.destination file("${buildDir}/jacocoHtml")
}
}
test {
finalizedBy jacocoTestReport // report is always generated after tests run
}
jacocoTestReport {
dependsOn test // tests are required to run before generating the report
}
tasks.named('test') {
useJUnitPlatform()
}

View File

@@ -4,7 +4,7 @@ REPOSITORY=/home/linux/app
echo "> 현재 구동 중인 애플리케이션 pid 확인"
CURRENT_PID=$(pgrep -fl action | grep java | awk '{print $1}')
CURRENT_PID=$(pgrep -fl java | awk '{print $1}')
echo "현재 구동 중인 애플리케이션 pid: $CURRENT_PID"

View File

@@ -2,16 +2,14 @@ package com.io.realworld.domain.aggregate.article.dto;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import lombok.*;
import java.util.List;
@Builder
@Getter
@ToString
@AllArgsConstructor
@JsonTypeName("article")
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.WRAPPER_OBJECT)
@@ -20,5 +18,4 @@ public class Articledto {
private String description;
private String body;
private List<String> tagList;
}

View File

@@ -22,4 +22,8 @@ public interface ArticleRepository extends JpaRepository<Article,Long> {
@EntityGraph(attributePaths = "tagList")
@Query("SELECT a FROM Article a LEFT JOIN Favorite f ON f.article.id = a.id WHERE f.user.username =:username ORDER BY a.createdDate DESC")
List<Article> findByFavoritedUser(String username, Pageable pageable);
@EntityGraph(attributePaths = "tagList" )
@Query("SELECT a FROM Article a ORDER BY a.createdDate DESC")
List<Article> findByAll(Pageable pageable);
}

View File

@@ -56,6 +56,8 @@ public class ArticleServiceImpl implements ArticleService {
articles = articleRepository.findByAuthorName(articleParam.getAuthor(), pageable);
}else if(articleParam.getFavorited() != null){
articles = articleRepository.findByFavoritedUser(articleParam.getFavorited(), pageable);
}else{
articles = articleRepository.findByAll(pageable);
}
return articles.stream().map(article -> {
@@ -71,7 +73,7 @@ public class ArticleServiceImpl implements ArticleService {
Pageable pageable = PageRequest.of(offset,limit);
List<Follow> follows = profileRepository.findByFollowerId(userAuth.getId());
List<Follow> follows = profileRepository.findByFolloweeId(userAuth.getId());
follows.stream().forEach(follow -> {
String followerName = follow.getFollower().getUsername();
articles.addAll(articleRepository.findByAuthorName(followerName,pageable));

View File

@@ -61,9 +61,10 @@ public class CommentServiceImpl implements CommentService {
if (article.isEmpty()) {
throw new CustomException(Error.ARTICLE_NOT_FOUND);
}
System.out.println(user.get().getUsername()+"!!");
Comment comment = commentRepository.save(Comment.builder().body(commentdto.getBody()).article(article.get()).author(user.get()).build());
return convertComment(userAuth, article.get(), comment);
return convertComment(userAuth, comment);
}
@Override
@@ -80,9 +81,9 @@ public class CommentServiceImpl implements CommentService {
commentRepository.delete(comment.get());
}
private CommentResponse convertComment(UserAuth userAuth, Article article, Comment comment) {
private CommentResponse convertComment(UserAuth userAuth, Comment comment) {
ProfileResponse profile = profileService.getProfile(userAuth, article.getAuthor().getUsername());
ProfileResponse profile = profileService.getProfile(userAuth, userAuth.getUsername());
return CommentResponse.builder()
.id(comment.getId())

View File

@@ -13,4 +13,6 @@ public interface ProfileRepository extends JpaRepository<Follow, Long> {
List<Follow> findByFollowerId(Long followeeId);
List<Follow> findByFolloweeId(Long followerId);
}

View File

@@ -22,7 +22,7 @@ public class ProfileServiceImpl implements ProfileService {
@Override
public ProfileResponse getProfile(UserAuth userAuth, String username) {
Optional<User> wantFindUser = Optional.ofNullable(userRepository.findByUsername(username).orElseThrow(() -> new CustomException(Error.USER_NOT_FOUND)));
Optional<User> wantFindUser = Optional.ofNullable(userRepository.findByUsername(username).orElseThrow(() -> {throw new CustomException(Error.USER_NOT_FOUND);}));
Boolean followStatus = null;
if(userAuth == null){
followStatus = false;
@@ -34,7 +34,7 @@ public class ProfileServiceImpl implements ProfileService {
@Override
public ProfileResponse followUser(UserAuth userAuth, String username) {
Optional<User> follower = Optional.ofNullable(userRepository.findByUsername(username).orElseThrow(() -> new CustomException(Error.USER_NOT_FOUND)));
Optional<User> follower = Optional.ofNullable(userRepository.findByUsername(username).orElseThrow(() -> {throw new CustomException(Error.USER_NOT_FOUND);}));
Optional<User> followee = userRepository.findById(userAuth.getId());
profileRepository.findByFolloweeIdAndFollowerId(followee.get().getId(), follower.get().getId()).ifPresent(follow -> {
throw new CustomException(Error.ALREADY_FOLLOW);
@@ -46,7 +46,7 @@ public class ProfileServiceImpl implements ProfileService {
@Override
public ProfileResponse unfollowUser(UserAuth userAuth, String username) {
Optional<User> follower = Optional.ofNullable(userRepository.findByUsername(username).orElseThrow(() -> new CustomException(Error.USER_NOT_FOUND)));
Optional<User> follower = Optional.ofNullable(userRepository.findByUsername(username).orElseThrow(() -> {throw new CustomException(Error.USER_NOT_FOUND);}));
Optional<User> followee = userRepository.findById(userAuth.getId());
Follow follow = profileRepository.findByFolloweeIdAndFollowerId(followee.get().getId(), follower.get().getId()).orElseThrow(() -> {
throw new CustomException(Error.ALREADY_UNFOLLOW);

View File

@@ -3,10 +3,7 @@ package com.io.realworld.domain.aggregate.user.controller;
import com.io.realworld.domain.aggregate.user.dto.UserSigninRequest;
import com.io.realworld.domain.aggregate.user.dto.UserSignupRequest;
import com.io.realworld.domain.aggregate.user.dto.UserResponse;
import com.io.realworld.domain.aggregate.user.entity.User;
import com.io.realworld.domain.service.JwtService;
import com.io.realworld.domain.aggregate.user.service.UserServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@@ -14,17 +11,13 @@ import javax.validation.Valid;
@RestController
@RequestMapping("/api/users")
public class UsersController {
private final UserServiceImpl userService;
public UsersController(UserServiceImpl userService) {
this.userService = userService;
}
@PostMapping(value = "")
@PostMapping
public UserResponse signup(@Valid @RequestBody UserSignupRequest userSignupRequest) {
return userService.signup(userSignupRequest);
}

View File

@@ -2,7 +2,6 @@ package com.io.realworld.domain.aggregate.user.dto;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@@ -10,7 +9,6 @@ import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
@Getter
@AllArgsConstructor
@Builder
@JsonTypeName("user")
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.WRAPPER_OBJECT)

View File

@@ -22,4 +22,6 @@ public class UserUpdate {
private String email;
private String bio;
private String image;
private String password;
private String username;
}

View File

@@ -83,4 +83,8 @@ public class User implements UserDetails {
this.bio = userUpdate.getBio();
this.image = userUpdate.getImage();
}
public void changePassword(String password) {
this.password = password;
}
}

View File

@@ -2,8 +2,6 @@ package com.io.realworld.domain.aggregate.user.repository;
import com.io.realworld.domain.aggregate.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import java.util.List;
import java.util.Optional;
@@ -16,4 +14,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
List<User> findAll();
List<User> findAllByUsername(String username);
}

View File

@@ -1,7 +1,6 @@
package com.io.realworld.domain.aggregate.user.service;
import com.io.realworld.domain.aggregate.user.dto.*;
import com.io.realworld.domain.aggregate.user.entity.User;
public interface UserService {
UserResponse signup(UserSignupRequest userSignupRequest);

View File

@@ -8,7 +8,6 @@ import com.io.realworld.domain.aggregate.user.entity.User;
import com.io.realworld.domain.aggregate.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -17,7 +16,6 @@ import java.util.Optional;
@Service
@RequiredArgsConstructor
@Log4j2
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
@@ -41,7 +39,8 @@ public class UserServiceImpl implements UserService {
return convertUser(userRepository.save(User.builder().
username(userSignupRequest.getUsername()).
email(userSignupRequest.getEmail()).
password(madeHash(userSignupRequest.getPassword())).build()));
password(madeHash(userSignupRequest.getPassword())).
image("https://api.realworld.io/images/smiley-cyrus.jpeg").build()));
}
}
@@ -55,7 +54,12 @@ public class UserServiceImpl implements UserService {
User findUser = userRepository.findByEmail(userSigninRequest.getEmail());
if (findUser == null) {
throw new CustomException(Error.EMAIL_NULL_OR_INVALID);
}else{
if(!passwordEncoder.matches(userSigninRequest.getPassword(), findUser.getPassword())){
throw new CustomException(Error.PASSWORD_WRONG);
}
}
return convertUser(findUser);
}
@@ -72,14 +76,23 @@ public class UserServiceImpl implements UserService {
@Override
@Transactional
public UserResponse updateUser(UserUpdate userUpdate, UserAuth userAuth){
User user = userRepository.findById(userAuth.getId()).orElseThrow(() -> new CustomException(Error.USER_NOT_FOUND));
User user = userRepository.findById(userAuth.getId()).orElseThrow(() -> {throw new CustomException(Error.USER_NOT_FOUND);});
if(userUpdate.getEmail() != null){
userRepository.findAllByEmail(userUpdate.getEmail())
.stream().filter(found -> !found.getId().equals(userRepository.findById(user.getId())))
.findAny().ifPresent(found -> new CustomException(Error.DUPLICATE_EMAIL));
.stream().filter(found -> !found.getId().equals(user.getId()))
.findFirst().ifPresent(found ->{throw new CustomException(Error.DUPLICATE_EMAIL);} );
user.changeEmail(userUpdate.getEmail());
}
if(userUpdate.getUsername() != null){
userRepository.findAllByUsername(userUpdate.getUsername())
.stream().filter(found -> !found.getId().equals(user.getId()))
.findFirst().ifPresent(found -> {throw new CustomException(Error.DUPLICATE_USERNAME);});
user.changeUsername(userUpdate.getUsername());
}
if(userUpdate.getPassword() != null){
user.changePassword(madeHash(userUpdate.getPassword()));
}
userUpdate.setId(user.getId());
user.update(userUpdate);
return convertUser(userRepository.save(user));

View File

@@ -11,6 +11,7 @@ public enum Error {
DUPLICATE_USERNAME("duplicate user username", HttpStatus.CONFLICT),
SIGNUP_NULL_DATA("request body include null",HttpStatus.BAD_REQUEST),
EMAIL_NULL_OR_INVALID("email is blank or invalid check plz",HttpStatus.BAD_REQUEST),
PASSWORD_WRONG("password is wrong.", HttpStatus.BAD_REQUEST),
USER_NOT_FOUND("user not found check your info",HttpStatus.NOT_FOUND),
ALREADY_FOLLOW("already follow",HttpStatus.UNPROCESSABLE_ENTITY),
ALREADY_UNFOLLOW("already unfollow",HttpStatus.UNPROCESSABLE_ENTITY),

View File

@@ -38,27 +38,19 @@ public class WebConfig {
}
@Bean
@Order(0)
public SecurityFilterChain resources(HttpSecurity http) throws Exception {
http.csrf().disable()
.requestMatchers((matchers) -> {matchers.antMatchers("/h2-console/**");
matchers.antMatchers(HttpMethod.GET,"/api/articles/**","/**");})
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf()
.disable()
.requestMatchers((matchers) -> {
matchers.antMatchers("/h2-console/**");
matchers.antMatchers(HttpMethod.GET,"/api/articles/:slug","/**");
matchers.mvcMatchers("/api/users/**");
})
.authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll())
.requestCache().disable()
.securityContext().disable()
.sessionManagement().disable()
.headers().frameOptions().disable();
return http.build();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf()
.disable()
.authorizeRequests()
.mvcMatchers("/api/users/**").permitAll()
.anyRequest().authenticated()
.headers().frameOptions().disable()
.and()
.formLogin()
.disable()

View File

@@ -3,6 +3,8 @@ package com.io.realworld.domain.aggregate.article.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.io.realworld.config.WithAuthUser;
import com.io.realworld.domain.aggregate.article.dto.*;
import com.io.realworld.domain.aggregate.article.entity.Article;
import com.io.realworld.domain.aggregate.article.repository.ArticleRepository;
import com.io.realworld.domain.aggregate.article.service.ArticleService;
import com.io.realworld.domain.aggregate.article.service.CommentService;
import com.io.realworld.domain.aggregate.user.dto.UserAuth;
@@ -56,9 +58,10 @@ class ArticleControllerTest {
private String slug;
@BeforeEach
void setup(){
String title = "create title";
slug = title.toLowerCase().replace(' ','-');
articleResponse = ArticleResponse.builder()

View File

@@ -120,7 +120,7 @@ class ArticleServiceImplTest {
}};
when(profileService.getProfile(eq(userAuth), any(String.class))).thenReturn(ProfileResponse.builder().username(articles.get(0).getAuthor().getUsername()).build());
when(profileRepository.findByFollowerId(any(Long.class))).thenReturn(follows);
when(profileRepository.findByFolloweeId(any(Long.class))).thenReturn(follows);
when(articleRepository.findByAuthorName(any(String.class),any(Pageable.class))).thenReturn(articles);
List<ArticleResponse> articleResponses = articleService.getFeed(userAuth, feedParam);

View File

@@ -1,6 +1,7 @@
package com.io.realworld.domain.aggregate.profile.repository;
import com.io.realworld.domain.aggregate.profile.entity.Follow;
import com.io.realworld.domain.aggregate.user.dto.UserAuth;
import com.io.realworld.domain.aggregate.user.entity.User;
import com.io.realworld.domain.aggregate.user.repository.UserRepository;
import org.junit.jupiter.params.ParameterizedTest;
@@ -8,6 +9,8 @@ import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
@@ -28,7 +31,7 @@ class ProfileRepositoryTest {
userRepository.save(followee);
userRepository.save(follower);
Follow follow = Follow.builder().followee(followee).follower(follower).build();
Follow follow = Follow.builder().id(1L).followee(followee).follower(follower).build();
//when
Follow getFollow = profileRepository.save(follow);
@@ -55,7 +58,7 @@ class ProfileRepositoryTest {
//given
userRepository.save(followee);
userRepository.save(follower);
Follow follow = Follow.builder().followee(followee).follower(follower).build();
Follow follow = Follow.builder().id(1L).followee(followee).follower(follower).build();
//when
profileRepository.save(follow);
Optional<Follow> getFollow = profileRepository.findByFolloweeIdAndFollowerId(followee.getId(),follower.getId());
@@ -67,7 +70,7 @@ class ProfileRepositoryTest {
}
@MethodSource("getFolloweeAndFollower")
@ParameterizedTest(name = "repo:팔로잉 제거 테스")
@ParameterizedTest(name = "repo:팔로잉 제거 테스")
void deleteFollow(User followee, User follower){
//given
userRepository.save(followee);
@@ -80,6 +83,37 @@ class ProfileRepositoryTest {
//then
}
@MethodSource("getFolloweeAndFollower")
@ParameterizedTest(name = "repo:피드 테스트")
void feedArticle(User followee, User follower){
User follower2 = User.builder()
.bio("follower bio")
.email("follower2@email.com")
.password("password")
.image("follower image")
.username("follower2")
.build();
userRepository.save(followee);
userRepository.save(follower);
userRepository.save(follower2);
UserAuth userAuth = UserAuth.builder().id(followee.getId()).username("username").bio("bio").email("email").build();
Follow follow = Follow.builder().followee(followee).follower(follower).build();
Follow follow2 = Follow.builder().followee(followee).follower(follower2).build();
profileRepository.save(follow);
profileRepository.save(follow2);
List<Follow> follows = profileRepository.findByFolloweeId(userAuth.getId());
assertThat(follows.get(0).getFollowee().getUsername()).isEqualTo(followee.getUsername());
assertThat(follows.get(0).getFollower().getUsername()).isEqualTo(follower.getUsername());
assertThat(follows.get(1).getFollowee().getUsername()).isEqualTo(followee.getUsername());
assertThat(follows.get(1).getFollower().getUsername()).isEqualTo(follower2.getUsername());
}
private static Stream<Arguments> getFolloweeAndFollower(){

View File

@@ -1,5 +1,7 @@
package com.io.realworld.domain.aggregate.user.repository;
import com.io.realworld.domain.aggregate.article.entity.Article;
import com.io.realworld.domain.aggregate.article.repository.ArticleRepository;
import com.io.realworld.domain.aggregate.user.entity.User;
import com.io.realworld.domain.aggregate.user.repository.UserRepository;
import org.hibernate.annotations.Filter;

View File

@@ -99,17 +99,17 @@ class UserServiceImplTest {
.bio("")
.email(userSigninRequest.getEmail())
.image("")
.password(userSigninRequest.getPassword())
.password(passwordEncoder.encode(userSigninRequest.getPassword()))
.build();
when(userRepository.findByEmail(any(String.class))).thenReturn(user);
userService.signin(userSigninRequest);
}
@MethodSource("invalidLoginUsers")
@ParameterizedTest(name = "sv:로그인 실패 테스트")
void loginFail(UserSigninRequest userSigninRequest){
when(userRepository.findByEmail(any(String.class))).thenReturn(null);
try {
userService.signin(userSigninRequest);
@@ -119,6 +119,25 @@ class UserServiceImplTest {
}
}
@MethodSource("invalidLoginUsers")
@ParameterizedTest(name="로그인 실패 테스트 : 패스워드 틀림")
void loginFailPasswordWrong(UserSigninRequest userSigninRequest){
User user = User.builder()
.bio("")
.email(userSigninRequest.getEmail())
.image("")
.password(passwordEncoder.encode("wrong password"))
.build();
when(userRepository.findByEmail(any(String.class))).thenReturn(user);
try{
userService.signin(userSigninRequest);
}catch (CustomException e){
assertThat(e.getError().equals(Error.PASSWORD_WRONG));
assertThat(e.getError().getMessage().equals(Error.PASSWORD_WRONG.getMessage()));
}
}
@Test
@DisplayName("현재 유저 가져오기 성공 테스트")
void currentUserSuccess(){

View File

@@ -3,7 +3,7 @@ spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create
spring.jpa.properties.hibernate.format_sql=true
spring.jpq.show-sql=true
logging.level.org.hibernate.type.descriptor.spl=trace
#secret
real-world.token.expiry=3000000

View File

@@ -16,12 +16,14 @@
"path": "^0.12.7",
"request": "^2.88.2",
"vue": "^3.2.41",
"vue-router": "^4.0.13"
"vue-router": "^4.0.13",
"vuex": "^4.0.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^3.2.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"sass": "^1.56.1",
"typescript": "^4.6.4",
"vite": "^3.2.3",
"vue-tsc": "^1.0.9"
@@ -510,6 +512,19 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"devOptional": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -608,6 +623,15 @@
"tweetnacl": "^0.14.3"
}
},
"node_modules/binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"devOptional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -623,6 +647,18 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"devOptional": true,
"dependencies": {
"fill-range": "^7.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/call-bind": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@@ -665,6 +701,33 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
"devOptional": true,
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1506,6 +1569,18 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"devOptional": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/flat-cache": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
@@ -1648,7 +1723,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"devOptional": true,
"dependencies": {
"is-glob": "^4.0.1"
},
@@ -1818,6 +1893,12 @@
"node": ">= 4"
}
},
"node_modules/immutable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz",
"integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==",
"devOptional": true
},
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -1873,6 +1954,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"devOptional": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@@ -1899,7 +1992,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"devOptional": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1931,7 +2024,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"devOptional": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
@@ -1954,6 +2047,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"devOptional": true,
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-typed-array": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz",
@@ -2158,6 +2260,15 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"devOptional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -2294,6 +2405,18 @@
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"devOptional": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.4.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.19.tgz",
@@ -2382,6 +2505,18 @@
"node": ">=0.6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"devOptional": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/regexpp": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
@@ -2525,6 +2660,23 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/sass": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.56.1.tgz",
"integrity": "sha512-VpEyKpyBPCxE7qGDtOcdJ6fFbcpOM+Emu7uZLxVrkX8KVU/Dp5UF7WLvzqRuUhB6mqqQt1xffLoG+AndxTZrCQ==",
"devOptional": true,
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/semver": {
"version": "7.3.8",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
@@ -2734,6 +2886,18 @@
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"devOptional": true,
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/tough-cookie": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
@@ -3037,6 +3201,17 @@
"typescript": "*"
}
},
"node_modules/vuex": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/vuex/-/vuex-4.0.2.tgz",
"integrity": "sha512-M6r8uxELjZIK8kTKDGgZTYX/ahzblnzC4isU1tpmEuOIIKmV+TRdc+H4s8ds2NuZ7wpUTdGRzJRtoj+lI+pc0Q==",
"dependencies": {
"@vue/devtools-api": "^6.0.0-beta.11"
},
"peerDependencies": {
"vue": "^3.0.2"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -3488,6 +3663,16 @@
"color-convert": "^2.0.1"
}
},
"anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"devOptional": true,
"requires": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
}
},
"argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -3571,6 +3756,12 @@
"tweetnacl": "^0.14.3"
}
},
"binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"devOptional": true
},
"boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -3586,6 +3777,15 @@
"balanced-match": "^1.0.0"
}
},
"braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"devOptional": true,
"requires": {
"fill-range": "^7.0.1"
}
},
"call-bind": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@@ -3616,6 +3816,22 @@
"supports-color": "^7.1.0"
}
},
"chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
"devOptional": true,
"requires": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"fsevents": "~2.3.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4151,6 +4367,15 @@
"flat-cache": "^3.0.4"
}
},
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"devOptional": true,
"requires": {
"to-regex-range": "^5.0.1"
}
},
"flat-cache": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
@@ -4275,7 +4500,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"devOptional": true,
"requires": {
"is-glob": "^4.0.1"
}
@@ -4373,6 +4598,12 @@
"integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
"dev": true
},
"immutable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz",
"integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==",
"devOptional": true
},
"import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -4413,6 +4644,15 @@
"has-tostringtag": "^1.0.0"
}
},
"is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"devOptional": true,
"requires": {
"binary-extensions": "^2.0.0"
}
},
"is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@@ -4430,7 +4670,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true
"devOptional": true
},
"is-fullwidth-code-point": {
"version": "3.0.0",
@@ -4450,7 +4690,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"devOptional": true,
"requires": {
"is-extglob": "^2.1.1"
}
@@ -4464,6 +4704,12 @@
"define-properties": "^1.1.3"
}
},
"is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"devOptional": true
},
"is-typed-array": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz",
@@ -4635,6 +4881,12 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
"normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"devOptional": true
},
"nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -4746,6 +4998,12 @@
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
},
"picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"devOptional": true
},
"postcss": {
"version": "8.4.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.19.tgz",
@@ -4803,6 +5061,15 @@
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz",
"integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA=="
},
"readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"devOptional": true,
"requires": {
"picomatch": "^2.2.1"
}
},
"regexpp": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
@@ -4897,6 +5164,17 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"sass": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.56.1.tgz",
"integrity": "sha512-VpEyKpyBPCxE7qGDtOcdJ6fFbcpOM+Emu7uZLxVrkX8KVU/Dp5UF7WLvzqRuUhB6mqqQt1xffLoG+AndxTZrCQ==",
"devOptional": true,
"requires": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
"source-map-js": ">=0.6.2 <2.0.0"
}
},
"semver": {
"version": "7.3.8",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
@@ -5048,6 +5326,15 @@
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true
},
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"devOptional": true,
"requires": {
"is-number": "^7.0.0"
}
},
"tough-cookie": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
@@ -5246,6 +5533,14 @@
"@volar/vue-typescript": "1.0.9"
}
},
"vuex": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/vuex/-/vuex-4.0.2.tgz",
"integrity": "sha512-M6r8uxELjZIK8kTKDGgZTYX/ahzblnzC4isU1tpmEuOIIKmV+TRdc+H4s8ds2NuZ7wpUTdGRzJRtoj+lI+pc0Q==",
"requires": {
"@vue/devtools-api": "^6.0.0-beta.11"
}
},
"which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -17,12 +17,14 @@
"path": "^0.12.7",
"request": "^2.88.2",
"vue": "^3.2.41",
"vue-router": "^4.0.13"
"vue-router": "^4.0.13",
"vuex": "^4.0.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^3.2.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"sass": "^1.56.1",
"typescript": "^4.6.4",
"vite": "^3.2.3",
"vue-tsc": "^1.0.9"

View File

@@ -1,5 +1,5 @@
<template>
<TheHeader></TheHeader>
<TheHeader :key="$route.fullPath"></TheHeader>
<router-view></router-view>
<TheFooter></TheFooter>
</template>
@@ -7,9 +7,14 @@
<script lang="ts">
import TheHeader from '@/components/TheHeader.vue'
import TheFooter from "@/components/TheFooter.vue";
import { useRoute } from 'vue-router';
export default {
name: 'App',
setup(){
const route = useRoute();
return route
},
components: {
TheHeader,
TheFooter
@@ -19,12 +24,5 @@ export default {
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>

View File

@@ -0,0 +1,232 @@
import axios, {AxiosResponse} from "axios";
const axiosService = axios.create({
baseURL: import.meta.env.VITE_BASE_URL,
})
const signUp = async (user: object): Promise<AxiosResponse> => {
return await axiosService.post('/api/users',{user});
}
const signIn = async (user: object): Promise<AxiosResponse> => {
return await axiosService.post('/api/users/login',{user})
}
const getCurrentUser = async (): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
return await axiosService.get('/api/user', {
headers: {
Authorization: "TOKEN " + currentToken
}
})
}
const updateUser = async (user: object): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
return await axiosService.put('/api/user', {user}, {
headers: {
Authorization: "TOKEN " + currentToken,
"Content-Type": `application/json`,
}
})
}
const getProfile = async (username: string | undefined): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
if(currentToken == null){
return axiosService.get('/api/profiles/' + username);
}else {
return axiosService.get('/api/profiles/' + username,{
headers:{
Authorization: "TOKEN " + currentToken,
}
})
}
}
const followUser = async (username: string | undefined): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
return await axiosService.post('/api/profiles/' + username + "/follow",{},{
headers:{
Authorization : "TOKEN " + currentToken,
"Content-Type": `application/json`,
}
})
}
const unfollowUser = async (username: string | undefined): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
return await axiosService.delete('/api/profiles/' + username + "/follow",{
headers:{
Authorization : "TOKEN " + currentToken,
}
})
}
const createArticle = async (article: object | undefined): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
return await axiosService.post('/api/articles', { article },{
headers :{
Authorization : "TOKEN " + currentToken,
"Content-Type": `application/json`,
}
})
}
const updateArticle = async (article: object | undefined, slug: string | undefined): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
return await axiosService.put('/api/articles/' + slug, { article },{
headers :{
Authorization : "TOKEN " + currentToken,
"Content-Type": `application/json`,
}
})
}
const deleteArticle = async (slug: string | undefined): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
return await axiosService.delete('/api/articles/' + slug,{
headers :{
Authorization : "TOKEN " + currentToken,
"Content-Type": `application/json`,
}
})
}
const listArticles = async (): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
if(currentToken == null){
return await axiosService.get('/api/articles?limit=1000&offset=0');
}else{
return await axiosService.get('/api/articles?limit=1000&offset=0',{
headers:{
Authorization: "TOKEN " + currentToken,
}
})
}
}
const listArticlesByUsername = async (author: string): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
if(currentToken == null){
return await axiosService.get('/api/articles?author=' + author);
}else{
return await axiosService.get('/api/articles?author=' + author,{
headers:{
Authorization: "TOKEN " + currentToken,
}
})
}
}
const listArticlesByFavorite = async (author: string): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
if(currentToken == null){
return await axiosService.get('/api/articles?favorited=' + author);
}else{
return await axiosService.get('/api/articles?favorited=' + author,{
headers:{
Authorization: "TOKEN " + currentToken,
}
})
}
}
const feedArticle = async (): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
return await axiosService.get('/api/articles/feed?limit=1000&offset=0',{
headers:{
Authorization: "TOKEN " + currentToken,
}
});
}
const getArticle = async (slug: string | undefined): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
if(currentToken == null){
return await axiosService.get('/api/articles/' + slug);
}else{
return await axiosService.get('/api/articles/' + slug,{
headers:{
Authorization: "TOKEN " + currentToken,
}
});
}
}
const addCommentToArticle = async (slug: string | undefined, comment: object): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
return await axiosService.post('/api/articles/' + slug + '/comments',{
comment
},{
headers:{
Authorization : "TOKEN " + currentToken,
"Content-Type": `application/json`,
}
});
}
const getCommentsFromArticle = async (slug: string | undefined): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
if(currentToken == null){
return await axiosService.get('/api/articles/' + slug + "/comments");
}else {
return await axiosService.get('/api/articles/' + slug + "/comments",{
headers:{
Authorization: "TOKEN " + currentToken,
}
})
}
}
const deleteCommentsFromArticle = async (slug: string | undefined, id: number): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
return await axiosService.delete('/api/articles/' + slug + '/comments/' + id,{
headers:{
Authorization : "TOKEN " + currentToken,
"Content-Type": `application/json`,
}
});
}
const favoriteArticle = async (slug: string | undefined): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
return await axiosService.post('/api/articles/' + slug + '/favorite',{},
{
headers:{
Authorization : "TOKEN " + currentToken,
"Content-Type": `application/json`,
}
});
}
const unFavoriteArticle = async (slug: string | undefined): Promise<AxiosResponse> => {
let currentToken = localStorage.getItem("token");
return await axiosService.delete('/api/articles/' + slug + '/favorite',{
headers:{
Authorization : "TOKEN " + currentToken,
"Content-Type": `application/json`,
}
});
}
const getTags = async (): Promise<AxiosResponse> => {
return await axiosService.get('/api/tags');
}
export { signUp, signIn,
getCurrentUser, updateUser,
getProfile, followUser,
createArticle, feedArticle,
listArticles, listArticlesByUsername,
unfollowUser, getArticle,
addCommentToArticle, getCommentsFromArticle,
favoriteArticle, unFavoriteArticle,
listArticlesByFavorite, updateArticle,
deleteArticle, deleteCommentsFromArticle,
getTags
}

View File

@@ -0,0 +1,109 @@
import { ref, Ref } from "@vue/reactivity";
import {listArticles, feedArticle, listArticlesByUsername, listArticlesByFavorite} from "@/api/index";
import { usePagination } from "@/ts/usePagination";
export interface Article {
slug: string,
title: string,
description: string,
favorited: boolean,
favoritesCount: number,
createdAt: string,
author: {
username: string,
image: string
}
}
export function usePaginationApi(
currentPage: Ref<number>,
rowsPerPage?: Ref<number>
) {
const articleLists: Ref<Article[]> = ref([]);
const listsAreLoading = ref(false);
const isEmpty = ref(false);
const { paginatedArray, numberOfPages } = usePagination<Article>({
rowsPerPage,
arrayToPaginate: articleLists,
currentPage
});
const feedLists = async () => {
listsAreLoading.value = true;
isEmpty.value = false;
try{
const { data } = await feedArticle();
articleLists.value = data.articles;
if(data.articlesCount == 0){
isEmpty.value = true;
}
} catch (err) {
console.log(err);
} finally {
listsAreLoading.value = false;
}
}
const loadLists = async () => {
listsAreLoading.value = true;
isEmpty.value = false;
try {
const { data } = await listArticles();
articleLists.value = data.articles;
if(data.articlesCount == 0){
isEmpty.value = true;
}
} catch (err) {
console.log(err);
} finally {
listsAreLoading.value = false;
}
};
const loadMyArticles = async (author: string) => {
listsAreLoading.value = true;
isEmpty.value = false;
try{
const { data } = await listArticlesByUsername(author);
articleLists.value = data.articles;
if(data.articlesCount == 0){
isEmpty.value = true;
}
}catch (err){
console.log(err);
}finally {
listsAreLoading.value = false;
}
}
const loadFavoriteArticles = async (author: string) => {
listsAreLoading.value = true;
isEmpty.value = false;
try{
const { data } = await listArticlesByFavorite(author);
articleLists.value = data.articles;
if(data.articlesCount == 0){
isEmpty.value = true;
}
}catch (err){
console.log(err);
}finally {
listsAreLoading.value = false;
}
}
return {
articleLists: paginatedArray,
loadLists,
feedLists,
loadMyArticles,
loadFavoriteArticles,
listsAreLoading,
isEmpty,
numberOfPages
};
}

View File

@@ -0,0 +1,624 @@
/* cyrillic-ext */
@font-face {
font-family: 'Merriweather Sans';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/merriweathersans/v22/2-c99IRs1JiJN1FRAMjTN5zd9vgsFHX4QjX78w.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* vietnamese */
@font-face {
font-family: 'Merriweather Sans';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/merriweathersans/v22/2-c99IRs1JiJN1FRAMjTN5zd9vgsFHX6QjX78w.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Merriweather Sans';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/merriweathersans/v22/2-c99IRs1JiJN1FRAMjTN5zd9vgsFHX7QjX78w.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Merriweather Sans';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/merriweathersans/v22/2-c99IRs1JiJN1FRAMjTN5zd9vgsFHX1QjU.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Merriweather Sans';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/merriweathersans/v22/2-c99IRs1JiJN1FRAMjTN5zd9vgsFHX4QjX78w.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* vietnamese */
@font-face {
font-family: 'Merriweather Sans';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/merriweathersans/v22/2-c99IRs1JiJN1FRAMjTN5zd9vgsFHX6QjX78w.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Merriweather Sans';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/merriweathersans/v22/2-c99IRs1JiJN1FRAMjTN5zd9vgsFHX7QjX78w.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Merriweather Sans';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/merriweathersans/v22/2-c99IRs1JiJN1FRAMjTN5zd9vgsFHX1QjU.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkidh18Smxg.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkido18Smxg.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkidg18Smxg.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkidv18Smxg.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkidj18Smxg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkidi18Smxg.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkids18Q.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7qsDJT9g.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7jsDJT9g.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7rsDJT9g.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7ksDJT9g.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7osDJT9g.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7psDJT9g.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7nsDI.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCdh18Smxg.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCdo18Smxg.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCdg18Smxg.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCdv18Smxg.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCdj18Smxg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCdi18Smxg.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCds18Q.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZclSdh18Smxg.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZclSdo18Smxg.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZclSdg18Smxg.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZclSdv18Smxg.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZclSdj18Smxg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZclSdi18Smxg.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZclSds18Q.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwmhduz8A.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwkxduz8A.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwmxduz8A.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwlBduz8A.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwmBduz8A.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwmRduz8A.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwlxdu.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xK3dSBYKcSV-LCoeQqfX1RYOo3qNa7lqDY.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xK3dSBYKcSV-LCoeQqfX1RYOo3qPK7lqDY.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xK3dSBYKcSV-LCoeQqfX1RYOo3qNK7lqDY.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xK3dSBYKcSV-LCoeQqfX1RYOo3qO67lqDY.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xK3dSBYKcSV-LCoeQqfX1RYOo3qN67lqDY.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xK3dSBYKcSV-LCoeQqfX1RYOo3qNq7lqDY.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xK3dSBYKcSV-LCoeQqfX1RYOo3qOK7l.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwmhduz8A.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwkxduz8A.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwmxduz8A.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwlBduz8A.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwmBduz8A.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwmRduz8A.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwlxdu.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwmhduz8A.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwkxduz8A.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwmxduz8A.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlBduz8A.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwmBduz8A.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwmRduz8A.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourcesanspro/v21/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlxdu.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourceserifpro/v15/neIQzD-0qpwxpaWvjeD0X88SAOeauXk-oBOL.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourceserifpro/v15/neIQzD-0qpwxpaWvjeD0X88SAOeauXA-oBOL.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek */
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourceserifpro/v15/neIQzD-0qpwxpaWvjeD0X88SAOeauXc-oBOL.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourceserifpro/v15/neIQzD-0qpwxpaWvjeD0X88SAOeauXs-oBOL.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourceserifpro/v15/neIQzD-0qpwxpaWvjeD0X88SAOeauXo-oBOL.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourceserifpro/v15/neIQzD-0qpwxpaWvjeD0X88SAOeauXQ-oA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourceserifpro/v15/neIXzD-0qpwxpaWvjeD0X88SAOeasc8btSGqxLUv.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourceserifpro/v15/neIXzD-0qpwxpaWvjeD0X88SAOeasc8btSiqxLUv.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek */
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourceserifpro/v15/neIXzD-0qpwxpaWvjeD0X88SAOeasc8btS-qxLUv.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourceserifpro/v15/neIXzD-0qpwxpaWvjeD0X88SAOeasc8btSOqxLUv.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourceserifpro/v15/neIXzD-0qpwxpaWvjeD0X88SAOeasc8btSKqxLUv.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/sourceserifpro/v15/neIXzD-0qpwxpaWvjeD0X88SAOeasc8btSyqxA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Titillium Web';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/titilliumweb/v15/NaPDcZTIAOhVxoMyOr9n_E7ffHjDGIVzY4SY.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Titillium Web';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/titilliumweb/v15/NaPDcZTIAOhVxoMyOr9n_E7ffHjDGItzYw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,97 @@
<template>
<div class="article-preview">
<div class="article-meta">
<a href="javascript:(0)" @click="showProfile(article.author.username)"><img :src="article.author.image"/></a>
<div class="info">
<a class="author"
href="javascript:void(0)"
@click="showProfile(article.author.username)">{{article.author.username}}</a>
<span class="date">{{convertDate(article.createdAt)}}</span>
</div>
<button class="btn btn-outline-primary btn-sm pull-xs-right">
<i class="ion-heart" @click="changeFavorite(article.slug, article.favorited)"></i> {{article.favoritesCount}}
</button>
</div>
<a href="javascript:(0)"
class="preview-link"
@click="showArticle(article.slug)">
<h1>{{article.title}}</h1>
<p>{{article.description}}</p>
<span>Read more...</span>
</a>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import convertDate from "@/ts/common";
import { favoriteArticle, unFavoriteArticle } from "@/api";
import router from "@/router";
import {useStore} from "vuex";
export default defineComponent({
name: "ArticleListFeed",
props: {
article: {
type: Object,
default: () => {
return {
slug: "",
title: "",
description: "",
favorited: false,
favoritesCount: 0,
createdAt: "",
author: {
username: "",
image: ""
}
}
}
}
},
setup(props){
const store = useStore();
const token = store.state.token;
const showProfile = (username: string) => {
router.push({
name: 'Profile',
params: {username: username}
})
}
const showArticle = (slug: string) =>{
router.push({
name: 'ArticleDetail',
params: {slug: slug}
})
}
const changeFavorite = async (slug: string, favorite : boolean) => {
if(token == ''){
await router.push({name:"Login"});
return;
}else{
if(favorite){
const { data } = await unFavoriteArticle(slug);
props.article.favoritesCount = data.article.favoritesCount;
props.article.favorited = data.article.favorited;
}else{
const { data } = await favoriteArticle(slug);
props.article.favoritesCount = data.article.favoritesCount;
props.article.favorited = data.article.favorited;
}
}
}
return { convertDate, changeFavorite, showProfile, showArticle }
}
})
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,96 @@
<template>
<div class="article-preview">
<div class="article-meta">
<a href="javascript:(0)" @click="showProfile(article.author.username)"><img :src="article.author.image"/></a>
<div class="info">
<a class="author"
href="javascript:void(0)"
@click="showProfile(article.author.username)">{{article.author.username}}</a>
<span class="date">{{convertDate(article.createdAt)}}</span>
</div>
<button class="btn btn-outline-primary btn-sm pull-xs-right">
<i class="ion-heart" @click="changeFavorite(article.slug, article.favorited)"></i> {{article.favoritesCount}}
</button>
</div>
<a href="javascript:(0)"
class="preview-link"
@click="showArticle(article.slug)">
<h1>{{article.title}}</h1>
<p>{{article.description}}</p>
<span>Read more...</span>
</a>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import router from "@/router";
import convertDate from '@/ts/common';
import { useStore } from "vuex";
import { favoriteArticle, unFavoriteArticle } from "@/api";
export default defineComponent({
name: "ArticleListGlobal",
props:{
index: Number,
article: {
type: Object,
default: () => {
return {
slug: "",
title: "",
description: "",
favorited: false,
favoritesCount: 0,
createdAt: "",
author: {
username: "",
image: ""
}
}
}
}
},
setup(props) {
const store = useStore();
const token = store.state.token;
const showProfile = (username: string) => {
router.push({
name: 'Profile',
params: {username: username}
})
}
const showArticle = (slug: string) =>{
router.push({
name: 'ArticleDetail',
params: {slug: slug}
})
}
const changeFavorite = async (slug: string, favorite : boolean) => {
if(token == ''){
await router.push({name:"Login"});
return;
}else{
if(favorite){
const { data } = await unFavoriteArticle(slug);
props.article.favoritesCount = data.article.favoritesCount;
props.article.favorited = data.article.favorited;
}else{
const { data } = await favoriteArticle(slug);
props.article.favoritesCount = data.article.favoritesCount;
props.article.favorited = data.article.favorited;
}
}
}
return { convertDate, changeFavorite, showProfile, showArticle }
}
})
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,73 @@
<template>
<div class="article-preview">
<div class="article-meta">
<a href="javascript:void(0)" ><img :src="article.author.image"/></a>
<div class="info">
<a href="javascript:void(0)" class="author">{{article.author.username}}</a>
<span class="date">{{convertDate(article.createdAt)}}</span>
</div>
<button class="btn btn-outline-primary btn-sm pull-xs-right">
<i class="ion-heart"></i> {{article.favoritesCount}}
</button>
</div>
<a href="javascript:(0)" class="preview-link"
@click="showArticle(article.slug)">
<h1>{{article.title}}</h1>
<p>{{article.description}}</p>
<span>Read more...</span>
</a>
</div>
</template>
<script lang="ts">
import {defineComponent} from "vue";
import router from "@/router";
import convertDate from "@/ts/common";
export default defineComponent({
name: "ArticleMy",
props:{
article: {
type: Object,
default: () =>{
return {
slug: "",
title: "",
description: "",
body: "",
tagList: new Array(),
createdAt: "",
favorited: false,
favoritesCount: 0,
author:{
username:"",
bio: "",
image: "",
following: false,
}
}
}
}
},
setup(props){
const showProfile = (username: string) => {
router.push({
name: 'Profile',
params: {username: username}
})
}
const showArticle = (slug: string) =>{
router.push({
name: 'ArticleDetail',
params: {slug: slug}
})
}
return { showProfile, convertDate, showArticle }
}
})
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,136 @@
<template>
<div class="pagination-container" aria-label="row pagination">
<ul v-if="numberOfPages >= 1" class="pagination">
<li
class="page-item"
aria-label="go to previous page"
@click="previous()"
:class="{
disabled: currentPage === 1,
}"
>
<span class="page-link">&laquo;</span>
</li>
<li
v-for="index in numberOfPages"
:key="index"
:aria-label="'go to page ' + index"
class="page-item"
@click="setCurrentPage(index)"
>
<div
class="page-link"
:class="{
'active-page': currentPage === index,
}"
>
{{ index }}
</div>
</li>
<li
class="page-item"
:class="{
disabled: currentPage === numberOfPages || !numberOfPages,
}"
aria-label="go to next page"
@click="next()"
>
<div class="page-link">&raquo;</div>
</li>
</ul>
</div>
</template>
<script lang="ts" setup>
import { toRefs } from "vue";
const props = defineProps({
numberOfPages: {
required: true,
type: Number
},
modelValue: {
required: true,
type: Number,
},
});
const { numberOfPages, modelValue: currentPage } = toRefs(props);
const emit = defineEmits(["update:modelValue"]);
const setCurrentPage = (number: Number) => {
emit("update:modelValue", number);
};
const previous = () => {
if (currentPage.value === 1) return;
emit("update:modelValue", currentPage.value - 1);
};
const next = () => {
if (currentPage.value >= numberOfPages.value) return;
emit("update:modelValue", currentPage.value + 1);
};
</script>
<style scoped lang="scss">
.pagination-container {
display: inline-block;
}
.pagination {
background: white;
margin: 0px;
padding: 10px;
display: flex;
gap: 5px;
align-items: center;
border: none;
box-sizing: border-box;
overflow: hidden;
word-wrap: break-word;
align-content: center;
border-radius: 14px;
}
.page-item {
display: flex;
cursor: pointer;
margin-bottom: 0px;
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Old versions of Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently supported by Chrome, Edge, Opera and Firefox */
}
.page-link {
color: #666B85;
border-radius: 5px;
padding: 10px 15px;
font-size: 14px;
font-weight: 800;
&:hover {
color: #333333;
background-color: #e9e9e9;
border: none;
}
}
.active-page {
background-color: #60d394 !important;
color: white !important;
&:hover {
border: none;
}
}
.disabled {
.page-link {
background-color: #f9fafb;
}
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,36 @@
<template>
<div class="tag-list">
<a href="javascript:(0)" class="tag-pill tag-default"
v-for = "tag in listTags.tags">{{tag}}</a>
</div>
</template>
<script lang="ts">
import {onMounted, defineComponent, reactive} from "vue";
import axios from "axios";
import {getTags} from "@/api";
export default defineComponent({
name: "TagList",
setup(){
const listTags = reactive({
tags: new Array()
})
onMounted(async () => {
try{
const { data } = await getTags();
listTags.tags = data.tags;
}catch (error: any){
alert(error);
}
})
return { listTags }
}
})
</script>
<style scoped>
</style>

View File

@@ -2,13 +2,12 @@
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<title>Conduit</title>
<!-- Import Ionicon icons & Google Fonts our Bootstrap theme relies on -->
<link href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css" rel="stylesheet" type="text/css">
<link href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic"
rel="stylesheet" type="text/css">
<!-- Import the custom Bootstrap 4 theme from our hosted CDN -->
<link rel="stylesheet" href="//demo.productionready.io/main.css">
<title>KMS-real-world</title>
<link
href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css"
rel="stylesheet"
type="text/css"
/>
</head>
<nav class="navbar navbar-light">
@@ -17,19 +16,29 @@
<ul class="nav navbar-nav pull-xs-right">
<li class="nav-item">
<!-- Add "active" class when you're on that page" -->
<router-link to="/" class="nav-link active" active-class="active">Home</router-link>
<router-link to="/" class="nav-link"
@click="selected(home)"
:class="{ active : home}" active-class="active">Home</router-link>
</li>
<li class="nav-item">
<router-link to="/article" class="nav-link" active-class="active">New Article</router-link>
<li class="nav-item" v-if="isLogin">
<router-link to="/editor/" class="nav-link"
@click="selected(article)"
:class="{ active : article}" active-class="active">New Article</router-link>
</li>
<li class="nav-item">
<router-link to="/settings" class="nav-link" active-class="active"><i class="ion-gear-a"></i>Settings</router-link>
<li class="nav-item" v-if="isLogin">
<router-link to="/settings" class="nav-link"
@click="selected(setting)"
:class="{ active : setting}" active-class="active"><i class="ion-gear-a"></i>Settings</router-link>
</li>
<li class="nav-item">
<router-link to="/login" class="nav-link" active-class="active">Sign in</router-link>
<li class="nav-item" v-if="!isLogin">
<router-link to="/login" class="nav-link"
@click="selected(signIn)"
:class="{ active : signIn}" active-class="active">Sign in</router-link>
</li>
<li class="nav-item">
<router-link to="/register" class="nav-link" active-class="active">Sign up</router-link>
<li class="nav-item" v-if="!isLogin">
<router-link to="/register" class="nav-link"
@click="selected(signUp)"
:class="{ active : signUp}" active-class="active">Sign up</router-link>
</li>
</ul>
</div>
@@ -39,8 +48,43 @@
</template>
<script lang="ts">
import { onMounted, ref } from "vue";
import { useStore } from "vuex";
export default {
name: "TheHeader"
name: "TheHeader",
setup(){
const store = useStore();
const isLogin = ref(false);
const home = ref(false);
const article = ref(false);
const setting = ref(false);
const signIn = ref(false);
const signUp = ref(false);
const allHide = () => {
home.value = false;
article.value = false;
setting.value = false;
signUp.value = false;
signIn.value = false;
}
const selected = (headerName : boolean) => {
allHide();
headerName = true;
}
onMounted(()=> {
if(store.state.token == ""){
isLogin.value = false;
}else{
isLogin.value = true;
}
})
return { isLogin, home, article, setting, signIn, signUp, allHide, selected }
}
}
</script>

View File

@@ -0,0 +1,74 @@
<template>
<div class="card">
<div class="card-block">
<p class="card-text">{{comment.body}}</p>
</div>
<div class="card-footer">
<a href="javascript:(0)" class="comment-author" @click="viewProfile">
<img :src="comment.author.image" class="comment-author-img"/>
</a>
<a href="javascript:(0)" class="comment-author" @click="viewProfile">{{comment.author.username}}</a>
<span class="date-posted">{{convertDate(comment.updatedAt)}}</span>
<span v-if="isMe" class="mod-options" @click="deleteComment">
<i class="ion-trash-a"></i>
</span>
</div>
</div>
</template>
<script lang="ts">
import {defineComponent, defineEmits, onMounted, ref,} from "vue";
import convertDate from "@/ts/common";
import router from "@/router";
import {useStore} from "vuex";
export default defineComponent({
name: "commentList",
props:{
comment: {
type: Object,
default: () =>{
return {
id: 0,
body: "",
updatedAt: "",
author:{
username:"",
image: "",
}
}
}
}
},
setup(props, {emit}){
const store = useStore();
const isMe = ref(false);
const viewProfile = () => {
router.push({
name: 'Profile',
params: {username: props.comment.author.username}})
}
const deleteComment = () => {
emit('delete:comment', props.comment.id);
}
onMounted(async () => {
if(store.state.username == props.comment.author.username){
isMe.value = true;
}else{
isMe.value = false;
}
})
return { isMe, convertDate, viewProfile, deleteComment}
}
})
</script>
<style scoped>
</style>

View File

@@ -1,7 +1,10 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from "./router";
import store from "./store";
import '@/assets/css/font.css'
import '@/assets/css/main.css'
const app = createApp(App);
app.use(router);
app.mount('#app');
app.config.performance=true;
app.use(router).use(store).mount('#app');

View File

@@ -23,10 +23,28 @@ const routes = [
component: () => import(/* webpackChunkName "inputTag" */ '@/views/TheRegister.vue')
},
{
path: "/article",
name: "Article",
path: "/article/:slug",
name: "ArticleDetail",
component: () => import('@/views/TheArticleDetail.vue'),
props: true
},
{
path: "/editor/",
name: "ArticleEditor",
component: () => import(/* webpackChunkName "inputTag" */ '@/views/TheArticle.vue')
},
{
path: "/editor/:slug",
name: "ArticleUpdateEditor",
component: () => import(/* webpackChunkName "inputTag" */ '@/views/ArticleUpdate.vue'),
props: true
},
{
path: "/@:username",
name: "Profile",
component: () => import(/* webpackChunkName "inputTag" */ '@/views/TheProfile.vue'),
props: true
}
];
const router = createRouter({

View File

@@ -0,0 +1,31 @@
import { createStore } from 'vuex';
export default createStore({
state: {
token: localStorage.getItem("token") || '',
username: localStorage.getItem("username") || '',
},
mutations: {
setUsername(state, username){
state.username = username;
},
setToken(state, token){
state.token = token;
},
},
actions: {
LOGIN({commit}, user){
commit("setUsername", user.username);
commit("setToken", user.token);
localStorage.setItem("username", user.username);
localStorage.setItem("token", user.token);
},
LOGOUT({commit}){
commit("setUsername","");
commit("setToken","");
localStorage.removeItem("username");
localStorage.removeItem("token");
},
},
}
)

View File

@@ -0,0 +1,5 @@
function convertDate(date: string): string {
return new Date(date).toLocaleDateString("en-US", { year: 'numeric', month: 'long', day: 'numeric' });
}
export default convertDate;

View File

@@ -0,0 +1,26 @@
import {computed, Ref, ref} from "vue";
interface PaginationConfig<T> {
rowsPerPage?: Ref<number>;
arrayToPaginate: Ref<T[]>;
currentPage: Ref<number>;
}
export function usePagination<T>(config: PaginationConfig<T>) {
const rowsPerPage = config.rowsPerPage || ref(20);
const paginatedArray = computed(() =>
config.arrayToPaginate.value.slice(
(config.currentPage.value - 1) * rowsPerPage.value,
config.currentPage.value * rowsPerPage.value
)
);
const numberOfPages = computed(() => {
return Math.ceil((config.arrayToPaginate.value.length || 0) / rowsPerPage.value)
}
);
return {
paginatedArray,
numberOfPages
};
}

View File

@@ -0,0 +1,70 @@
<template>
<div class="editor-page">
<div class="container page">
<div class="row">
<div class="col-md-10 offset-md-1 col-xs-12">
<form>
<fieldset>
<fieldset class="form-group">
<input type="text" class="form-control form-control-lg" placeholder="Article Title" v-model="article.title">
</fieldset>
<fieldset class="form-group">
<input type="text" class="form-control" placeholder="What's this article about?" v-model="article.description">
</fieldset>
<fieldset class="form-group">
<textarea class="form-control" rows="8"
placeholder="Write your article (in markdown)" v-model="article.body"></textarea>
</fieldset>
<fieldset class="form-group">
<input type="text" class="form-control" placeholder="Enter tags using ',' Add tag">
<div class="tag-list"></div>
</fieldset>
<button @click="updateContent" class="btn btn-lg pull-xs-right btn-primary" type="button">
Publish Article
</button>
</fieldset>
</form>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { reactive, defineComponent } from "vue";
import { updateArticle } from "@/api/index.js";
import router from "@/router";
export default defineComponent({
name: "ArticleUpdate",
props:{
slug: String,
},
setup(props){
const article = reactive({
title: "",
description: "",
body: "",
})
const updateContent = async () => {
try{
const { data } = await updateArticle(article, props.slug);
await router.push({
name:"ArticleDetail",
params: {slug: data.article.slug}
})
}catch (error: any){
alert(error);
}
}
return { article, updateContent }
}
})
</script>
<style scoped>
</style>

View File

@@ -1,33 +1,30 @@
<template>
<div class="editor-page">
<div class="container page">
<div class="row">
<div class="col-md-10 offset-md-1 col-xs-12">
<form>
<fieldset>
<fieldset class="form-group">
<input type="text" class="form-control form-control-lg" placeholder="Article Title">
<input type="text" class="form-control form-control-lg" placeholder="Article Title" v-model="article.title">
</fieldset>
<fieldset class="form-group">
<input type="text" class="form-control" placeholder="What's this article about?">
<input type="text" class="form-control" placeholder="What's this article about?" v-model="article.description">
</fieldset>
<fieldset class="form-group">
<textarea class="form-control" rows="8"
placeholder="Write your article (in markdown)"></textarea>
placeholder="Write your article (in markdown)" v-model="article.body"></textarea>
</fieldset>
<fieldset class="form-group">
<input type="text" class="form-control" placeholder="Enter tags">
<input type="text" class="form-control" placeholder="Enter tags using ',' Add tag" v-model="tag">
<div class="tag-list"></div>
</fieldset>
<button class="btn btn-lg pull-xs-right btn-primary" type="button">
<button @click="addArticle" class="btn btn-lg pull-xs-right btn-primary" type="button">
Publish Article
</button>
</fieldset>
</form>
</div>
</div>
</div>
</div>
@@ -36,8 +33,46 @@
</template>
<script lang="ts">
import {reactive ,ref } from "vue";
import router from "@/router";
import {createArticle} from "@/api";
export default {
name: "TheArticle"
name: "TheArticle",
setup(){
const tag = ref("");
const article = reactive({
title: "",
description: "",
body: "",
tagList: new Array(),
})
const parsingTag = () => {
let tags: string = tag.value;
return tags.split(',');
}
const getSlug = (title:string) => {
return title.replace(' ','-');
}
const addArticle = async () => {
const tags = parsingTag();
const slug = getSlug(article.title);
article.tagList = tags;
try{
await createArticle(article);
await router.push({
name:"ArticleDetail",
params: {slug}
})
}catch (error: any){
alert(error);
}
}
return { article, tag, addArticle, parsingTag, getSlug }
}
}
</script>

View File

@@ -0,0 +1,283 @@
<template>
<div class="article-page">
<div class="banner">
<div class="container">
<h1>{{ articleDetail.article.title }}</h1>
<div class="article-meta">
<a href=""><img :src="articleDetail.article.author.image"/></a>
<div class="info">
<a href="javascript:void(0)" class="author" @click="viewProfile">{{ articleDetail.article.author.username }}</a>
<span class="date">{{convertDate(articleDetail.article.createdAt)}}</span>
</div>
<button v-if= "isMe" class="btn btn-sm btn-outline-secondary" @click="articleUpdate()">
<i class="ion-edit"></i>
Edit Article
</button>
<button v-if= "isMe" class="btn btn-outline-danger btn-sm" @click="articleDelete()">
<i class="ion-trash-a"></i>
Delete Article
</button>
<button v-if= "!isMe" class="btn btn-sm btn-outline-secondary" @click="followUpdate(articleDetail.article.author.following)">
<div v-if="articleDetail.article.author.following">
<i class="ion-minus-round"></i>
unFollow {{articleDetail.article.author.username}}
</div>
<div v-else>
<i class="ion-plus-round"></i>
Follow {{articleDetail.article.author.username}}
</div>
</button>
&nbsp;&nbsp;
<button v-if="!isMe" class="btn btn-sm btn-outline-primary" @click="favoriteUpdate(articleDetail.article.favorited)">
<div v-if="articleDetail.article.favorited">
<i class="ion-heart"></i>
unFavorite Article (<span class="counter">{{articleDetail.article.favoritesCount}}</span>)
</div>
<div v-else>
<i class="ion-heart"></i>
Favorite Article (<span class="counter">{{articleDetail.article.favoritesCount}}</span>)
</div>
</button>
</div>
</div>
</div>
<div class="container page">
<div class="row article-content">
<div class="col-md-12">
{{articleDetail.article.body}}
</div>
</div>
<hr/>
<div class="article-actions">
<div class="article-meta">
<a href="javascript:void(0)"><img :src="articleDetail.article.author.image"/></a>
<div class="info">
<a href="javascript:void(0)" class="author" @click="viewProfile">{{ articleDetail.article.author.username }}</a>
<span class="date">{{convertDate(articleDetail.article.createdAt)}}</span>
</div>
<button v-if= "isMe" class="btn btn-sm btn-outline-secondary" @click="articleUpdate()">
<i class="ion-edit"></i>
Edit Article
</button>
<button v-if= "isMe" class="btn btn-outline-danger btn-sm" @click="articleDelete()">
<i class="ion-trash-a"></i>
Delete Article
</button>
<button v-if= "!isMe" class="btn btn-sm btn-outline-secondary" @click="followUpdate(articleDetail.article.author.following)">
<div v-if="articleDetail.article.author.following">
<i class="ion-minus-round"></i>
unFollow {{articleDetail.article.author.username}}
</div>
<div v-else>
<i class="ion-plus-round"></i>
Follow {{articleDetail.article.author.username}}
</div>
</button>
&nbsp;&nbsp;
<button v-if="!isMe" class="btn btn-sm btn-outline-primary" @click="favoriteUpdate(articleDetail.article.favorited)">
<div v-if="articleDetail.article.favorited">
<i class="ion-heart"></i>
unFavorite Article (<span class="counter">{{articleDetail.article.favoritesCount}}</span>)
</div>
<div v-else>
<i class="ion-heart"></i>
Favorite Article (<span class="counter">{{articleDetail.article.favoritesCount}}</span>)
</div>
</button>
</div>
</div>
<div class="row">
<div class="col-xs-12 col-md-8 offset-md-2">
<form class="card comment-form">
<div class="card-block">
<textarea class="form-control" placeholder="Write a comment..." rows="3" v-model="comment.body"></textarea>
</div>
<div class="card-footer">
<img :src="articleDetail.article.author.image" class="comment-author-img"/>
<button class="btn btn-sm btn-primary" @click="sendComment()">
Post Comment
</button>
</div>
</form>
<comment-list v-for="(comment,index) in getCommentList.comment"
:key="comment.id"
:comment="comment"
@delete:comment="deleteComment"
:imgs="comment.author.image">
</comment-list>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import {onMounted, defineComponent, reactive, ref} from "vue";
import commentList from "@/components/commentList.vue";
import router from "@/router";
import { useStore } from "vuex";
import convertDate from "@/ts/common";
import {
addCommentToArticle, deleteArticle, deleteCommentsFromArticle,
favoriteArticle,
followUser,
getArticle,
getCommentsFromArticle, unFavoriteArticle,
unfollowUser
} from "@/api";
export default defineComponent({
name: "TheArticleDetail.vue",
components:{
'comment-list': commentList,
},
props:{
slug: String,
},
setup(props){
const store = useStore();
const token = store.state.token;
const username = store.state.username;
const isMe = ref(false);
const comment = reactive({
body: ""
})
const getCommentList = reactive({
comment: reactive([{id:0,author:{username:"",image:""}}])
})
const articleDetail = reactive({
article: {
slug: "",
title: "",
description: "",
body: "",
tagList: new Array(),
createdAt: "",
updatedAt: "",
favorited: false,
favoritesCount: 0,
author: {
username: "",
bio: "",
image: "",
following: false
}
}
})
const viewProfile = () => {
router.push({
name: 'Profile',
params: {username: articleDetail.article.author.username}})
}
const articleUpdate = async () => {
await router.push({
name: 'ArticleUpdateEditor',
params: {slug: articleDetail.article.slug}
})
}
const articleDelete = async () => {
await deleteArticle(articleDetail.article.slug);
await router.push({
name: 'Home'
})
}
const followUpdate = async (followState: boolean) => {
if(token == ''){
await router.push({name:"Login"});
return;
}
try {
if(followState){
const { data } = await unfollowUser(articleDetail.article.author.username);
articleDetail.article.author.following = data.profile.following;
}else{
const { data } = await followUser(articleDetail.article.author.username);
articleDetail.article.author.following = data.profile.following;
}
}catch (error: any){
alert(error);
}
}
const favoriteUpdate = async (favoriteState: boolean) => {
if(token == ''){
await router.push({name:"Login"});
return;
}
try {
if (favoriteState) {
const {data} = await unFavoriteArticle(props.slug);
articleDetail.article = data.article;
} else {
const {data} = await favoriteArticle(props.slug);
articleDetail.article = data.article;
}
}catch (error: any){
alert(error);
}
}
const sendComment = async () => {
try{
const { data } = await addCommentToArticle(props.slug, comment);
comment.body = "";
getCommentList.comment.push(data.comment);
}catch (error: any){
alert(error);
}
}
const deleteComment = async (commentId: number) => {
await deleteCommentsFromArticle(articleDetail.article.slug,commentId);
getCommentList.comment.splice(commentId,1);
}
onMounted(async ()=>{
try{
const { data } = await getArticle(props.slug);
articleDetail.article = data.article;
if(articleDetail.article.author.username == username){
isMe.value = true;
}
}catch (error: any){
alert(error);
}
try{
const { data } = await getCommentsFromArticle(props.slug);
getCommentList.comment = data.comments;
}catch (error: any){
alert(error);
}
})
return { isMe, articleDetail, comment, getCommentList, convertDate, deleteComment, viewProfile, articleUpdate, followUpdate, favoriteUpdate, sendComment, articleDelete }
}
})
</script>
<style scoped>
</style>

View File

@@ -13,84 +13,107 @@
<div class="row">
<div class="col-md-9">
<div class="feed-toggle">
<ul class="nav nav-pills outline-active">
<li class="nav-item">
<a class="nav-link disabled" href="">Your Feed</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="">Global Feed</a>
</li>
</ul>
</div>
<div class="article-preview">
<div class="article-meta">
<a href="profile.html"><img src="http://i.imgur.com/Qr71crq.jpg"/></a>
<div class="info">
<a href="" class="author">Eric Simons</a>
<span class="date">January 20th</span>
<div class="feed-toggle">
<ul class="nav nav-pills outline-active">
<li class="nav-item" v-if="isLogin">
<a href="javascript:(0)" class="nav-link"
@click="feedSelect"
:class="{ active : feedActive}">Your Feed</a>
</li>
<li class="nav-item">
<a href="javascript:(0)" class="nav-link"
@click="globalSelect"
:class="{ active : globalActive }">Global Feed</a>
</li>
</ul>
<div v-if="listsAreLoading">
Loading articles...
</div>
<button class="btn btn-outline-primary btn-sm pull-xs-right">
<i class="ion-heart"></i> 29
</button>
</div>
<a href="" class="preview-link">
<h1>How to build webapps that scale</h1>
<p>This is the description for the post.</p>
<span>Read more...</span>
</a>
</div>
<div class="article-preview">
<div class="article-meta">
<a href="profile.html"><img src="http://i.imgur.com/N4VcUeJ.jpg"/></a>
<div class="info">
<a href="" class="author">Albert Pai</a>
<span class="date">January 20th</span>
<div v-if="isEmpty">
No articles are here... yet.
</div>
<button class="btn btn-outline-primary btn-sm pull-xs-right">
<i class="ion-heart"></i> 32
</button>
</div>
<a href="" class="preview-link">
<h1>The song you won't ever stop singing. No matter how hard you try.</h1>
<p>This is the description for the post.</p>
<span>Read more...</span>
</a>
<div v-if="feedActive && isLogin">
<article-list-feed v-for="(article,index) in articleLists"
:key="article.slug"
:article="article">
</article-list-feed>
</div>
<div v-else>
<article-list-global v-for="(article,index) in articleLists"
:key="article.slug"
:article="article">
</article-list-global>
</div>
</div>
</div>
<div class="col-md-3">
<div class="sidebar">
<p>Popular Tags</p>
<div class="tag-list">
<a href="" class="tag-pill tag-default">programming</a>
<a href="" class="tag-pill tag-default">javascript</a>
<a href="" class="tag-pill tag-default">emberjs</a>
<a href="" class="tag-pill tag-default">angularjs</a>
<a href="" class="tag-pill tag-default">react</a>
<a href="" class="tag-pill tag-default">mean</a>
<a href="" class="tag-pill tag-default">node</a>
<a href="" class="tag-pill tag-default">rails</a>
</div>
<tag-lists></tag-lists>
</div>
</div>
</div>
<pagination-component v-model="currentPage" :numberOfPages="numberOfPages"></pagination-component>
</div>
</div>
</template>
<script lang="ts">
import articleList from '@/components/ArticleListFeed.vue'
import articleListGlobal from "@/components/ArticleListGlobal.vue";
import tagLists from "@/components/TagList.vue";
import pagination from "@/components/PaginationComponent.vue";
import { usePaginationApi } from "@/api/usePaginationAPI"
import {onMounted, ref} from "vue";
import { useStore } from "vuex";
export default {
name: "TheHome"
name: "TheHome",
components: {
'article-list-feed': articleList,
'article-list-global': articleListGlobal,
'tag-lists': tagLists,
'pagination-component': pagination,
},
setup(){
const store = useStore();
const isLogin = ref(false);
const feedActive = ref(true);
const globalActive = ref(false);
const currentPage = ref(1);
const rowsPerPage = ref(20);
const { articleLists, listsAreLoading, isEmpty, loadLists, feedLists, numberOfPages } = usePaginationApi(currentPage, rowsPerPage);
const feedSelect = async () => {
feedActive.value=true;
globalActive.value=false;
await feedLists();
}
const globalSelect = async () => {
feedActive.value=false;
globalActive.value=true;
await loadLists();
}
onMounted(async () => {
isLogin.value = store.state.token ? true : false;
if(isLogin.value == false) {
await loadLists();
globalActive.value = true;
}else{
await feedLists();
feedActive.value = true;
}
})
return { listsAreLoading, isEmpty, isLogin, currentPage, rowsPerPage, numberOfPages, feedActive, globalActive, articleLists, feedSelect, globalSelect };
}
}
</script>
<style scoped>
<style lang="scss">
</style>

View File

@@ -21,7 +21,7 @@
<fieldset class="form-group">
<input class="form-control form-control-lg" type="password" placeholder="Password" v-model="user.password">
</fieldset>
<button @click = "signin" class="btn btn-lg btn-primary pull-xs-right">
<button @click = "Login" class="btn btn-lg btn-primary pull-xs-right">
Sign in
</button>
</form>
@@ -33,9 +33,10 @@
</template>
<script lang="ts">
import {reactive, ref} from "vue";
import axios from "axios";
import { reactive, ref } from "vue";
import { signIn } from "@/api";
import router from "@/router";
import { useStore } from "vuex";
export default {
name: "TheLogin",
@@ -46,26 +47,25 @@ export default {
password: "",
})
const store = useStore();
let loginValidation = ref(false);
const signin = () => {
const url = import.meta.env.VITE_BASE_URL;
axios.post(url+'/api/users/login',{
user
})
.then(response => {
window.localStorage.setItem("token",response.data.user.token);
router.push("/");
})
.catch(error =>{
console.log(error);
const code = error.response.data.errors.code;
if(code == "EMAIL_NULL_OR_INVALID"){
loginValidation.value = true;
}
})
const Login = async () => {
try{
const { data } = await signIn(user);
await store.dispatch("LOGIN",data.user);
await router.push({name:"Home"});
}catch(error: any){
const code = error.response.data.errors.code;
if(code == "EMAIL_NULL_OR_INVALID"){
loginValidation.value = true;
}else if(code == "PASSWORD_WRONG"){
loginValidation.value = true;
}
};
}
return {user, loginValidation, signin};
return {user, loginValidation, Login};
}

View File

@@ -0,0 +1,175 @@
<template>
<div class="profile-page">
<div class="user-info">
<div class="container">
<div class="row">
<div class="col-xs-12 col-md-10 offset-md-1">
<img :src="profile.image" class="user-img"/>
<h4>{{ profile.username }}</h4>
<p>
{{ profile.bio }}
</p>
<button class="btn btn-sm btn-outline-secondary action-btn" @click="stateUpdate">
<div v-if="isMe">
<i class="ion-gear-a"></i>
Edit Profile Settings
</div>
<div v-else>
<div v-if="profile.following">
<i class="ion-minus-round"></i>
unFollow {{profile.username}}
</div>
<div v-else>
<i class="ion-plus-round"></i>
Follow {{profile.username}}
</div>
</div>
</button>
</div>
</div>
</div>
</div>
<div class="container">
<div class="row">
<div class="col-xs-12 col-md-10 offset-md-1">
<div class="articles-toggle">
<ul class="nav nav-pills outline-active">
<li class="nav-item">
<a href="javascript:(0)" class="nav-link"
@click="myArticleSelect"
:class="{ active : myArticleActive}">My Articles</a>
</li>
<li class="nav-item">
<a href="javascript:(0)" class="nav-link"
@click="favoriteArticleSelect"
:class="{ active : favoriteArticleActive}">Favorited Articles</a>
</li>
</ul>
<div v-if="listsAreLoading">
Loading articles...
</div>
<div v-if="isEmpty">
No articles are here... yet.
</div>
</div>
<article-my v-for="(article,index) in articleLists"
:key="article.slug"
:article="article">
</article-my>
<pagination-component v-model="currentPage" :numberOfPages="numberOfPages"></pagination-component>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { onMounted, reactive, ref } from "vue";
import { useStore } from "vuex";
import { defineComponent } from 'vue';
import pagination from "@/components/PaginationComponent.vue";
import { usePaginationApi } from "@/api/usePaginationAPI"
import router from "@/router";
import { followUser, getProfile, unfollowUser } from "@/api";
import ArticleMy from "@/components/ArticleMy.vue";
export default defineComponent({
name: "TheProfile.vue",
components: {
"article-my": ArticleMy,
'pagination-component': pagination,
},
props:{
username: String,
},
setup(props){
const store = useStore();
const isMe = ref(false);
const profile = reactive({
image: "",
username: "",
bio: "",
following: false,
})
const myArticleActive = ref(true);
const favoriteArticleActive = ref(false);
const currentPage = ref(1);
const rowsPerPage = ref(5);
const { articleLists, listsAreLoading, isEmpty,loadMyArticles, loadFavoriteArticles, numberOfPages } = usePaginationApi(currentPage, rowsPerPage);
const setProfile = async ( data: any ) => {
profile.image = data.image;
profile.bio = data.bio;
profile.following = data.following;
profile.username = data.username;
}
const stateUpdate = async () => {
if(isMe.value){
await router.push({name:"Settings"});
}else{
if(profile.following){
try {
const { data } = await unfollowUser(profile.username);
await setProfile(data.profile);
}catch (error: any){
alert("error");
}
}else{
try {
const { data } = await followUser(profile.username);
await setProfile(data.profile);
}catch (error: any){
alert("error");
}
}
}
}
const myArticleSelect = async () => {
myArticleActive.value=true;
favoriteArticleActive.value=false;
await loadMyArticles(profile.username);
}
const favoriteArticleSelect = async () => {
myArticleActive.value=false;
favoriteArticleActive.value=true;
await loadFavoriteArticles(profile.username);
}
onMounted(async () =>{
try {
const { data } = await getProfile(props.username)
await setProfile(data.profile);
if(data.profile.username.localeCompare(store.state.username) == 0){
isMe.value = true;
}else{
isMe.value = false;
}
}catch (error: any){
const code = error.response.data.errors.code;
if(code == "USER_NOT_FOUND")
await router.push({name:"home"});
}
try{
await loadMyArticles(profile.username);
}catch (error: any){
alert(error);
}
})
return { isMe, listsAreLoading, isEmpty, myArticleActive, favoriteArticleActive, profile, articleLists, currentPage, rowsPerPage, stateUpdate, myArticleSelect, favoriteArticleSelect, numberOfPages }
}
})
</script>
<style scoped>
</style>

View File

@@ -16,8 +16,6 @@
<ul class="error-messages" v-if="usernameDuplicate">
<li align="left">username has already been taken</li>
</ul>
<form>
<fieldset class="form-group">
<input class="form-control form-control-lg" type="text" placeholder="Your Name" v-model="user.username">
@@ -28,7 +26,7 @@
<fieldset class="form-group">
<input class="form-control form-control-lg" type="password" placeholder="Password" v-model="user.password">
</fieldset>
<button @click = "signup" class="btn btn-lg btn-primary pull-xs-right">
<button @click = "register" class="btn btn-lg btn-primary pull-xs-right">
Sign up
</button>
</form>
@@ -41,9 +39,10 @@
<script lang="ts">
import axios from "axios";
import { signUp } from "@/api";
import router from "@/router";
import {reactive, ref} from "vue";
import { reactive, ref } from "vue";
import { useStore } from "vuex";
export default {
name: "TheRegister.vue",
@@ -51,17 +50,13 @@ export default {
let emailDuplicate = ref(false);
let usernameDuplicate = ref(false);
const store = useStore();
const user = reactive({
username: "",
email: "",
password: "",
})
const allHideError = () => {
emailDuplicate.value = false;
usernameDuplicate.value = false;
}
const showEmailUsernameError = () => {
emailDuplicate.value = true;
usernameDuplicate.value = true
@@ -76,30 +71,24 @@ export default {
emailDuplicate.value = false;
};
const signup = () => {
const url = import.meta.env.VITE_BASE_URL;
axios.post(url+'/api/users',{
user
})
.then(response => {
window.localStorage.setItem("token",response.data.user.token);
allHideError();
router.push("/");
})
.catch(error =>{
console.log(error);
const code = error.response.data.errors.code;
if(code == "DUPLICATE_EMAIL_USERNAME"){
showEmailUsernameError();
}else if(code == "DUPLICATE_EMAIL"){
showEmailError();
}else if(code == "DUPLICATE_USERNAME"){
showUsernameError();
}
})
const register = async () => {
try{
const { data } = await signUp(user);
store.dispatch("LOGIN",data.user);
router.push({name:"Home"});
}catch(error: any){
const code = error.response.data.errors.code;
if(code == "DUPLICATE_EMAIL_USERNAME"){
showEmailUsernameError();
}else if(code == "DUPLICATE_EMAIL"){
showEmailError();
}else if(code == "DUPLICATE_USERNAME"){
showUsernameError();
}
}
}
return { user, emailDuplicate, usernameDuplicate, signup, showEmailUsernameError,showEmailError, showUsernameError, allHideError }
return { user, emailDuplicate, usernameDuplicate, register, showEmailUsernameError,showEmailError, showUsernameError }
},
}

View File

@@ -8,28 +8,39 @@
<h1 class="text-xs-center">Your Settings</h1>
<form>
<ul class="error-messages" v-if="emailDuplicate">
<li align="left">email has already been taken</li>
</ul>
<ul class="error-messages" v-if="usernameDuplicate">
<li align="left">username has already been taken</li>
</ul>
<fieldset>
<fieldset class="form-group">
<input class="form-control" type="text" placeholder="URL of profile picture">
<input class="form-control" type="text" placeholder="URL of profile picture" v-model="user.image">
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg" type="text" placeholder="Your Name">
<input class="form-control form-control-lg" type="text" placeholder="Your Name" v-model="user.username">
</fieldset>
<fieldset class="form-group">
<textarea class="form-control form-control-lg" rows="8"
placeholder="Short bio about you"></textarea>
<textarea class="form-control form-control-lg" rows="8" placeholder="Short bio about you" v-model="user.bio">
</textarea>
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg" type="text" placeholder="Email">
<input class="form-control form-control-lg" type="text" placeholder="Email" v-model="user.email">
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg" type="password" placeholder="Password">
<input class="form-control form-control-lg" type="password" placeholder="Password" v-model="password">
</fieldset>
<button class="btn btn-lg btn-primary pull-xs-right">
<button @click="setUser" class="btn btn-lg btn-primary pull-xs-right">
Update Settings
</button>
</fieldset>
</form>
<hr>
<button class="btn btn-outline-danger" @click="logout">
Or click here to logout.
</button>
</div>
</div>
@@ -38,8 +49,84 @@
</template>
<script lang="ts">
import { getCurrentUser, updateUser } from "@/api";
import { useStore } from "vuex";
import router from "@/router";
import {onMounted, reactive, ref} from "vue";
export default {
name: "TheSetting"
name: "TheSetting",
setup() {
const url = import.meta.env.VITE_BASE_URL;
const user = reactive({
bio: "",
email: "",
image: "",
username: "",
password: "",
})
const password = "";
const store = useStore();
const emailDuplicate = ref(false);
const usernameDuplicate = ref(false);
const getUser = async (getuser: { bio: string; email: string; username: string; image: string; }) => {
user.bio = getuser.bio
user.email = getuser.email
user.username = getuser.username
user.image = getuser.image
}
const showEmailError = () => {
emailDuplicate.value = true;
usernameDuplicate.value = false;
};
const showUsernameError = () => {
usernameDuplicate.value = true;
emailDuplicate.value = false;
};
const setUser = async () => {
try{
const { data } = await updateUser(user);
await store.dispatch("LOGIN", data.user);
await router.push({
name: 'Profile',
params: {username: data.user.username}
})
}catch(error: any){
const code = error.response.data.errors.code;
if(code == "DUPLICATE_EMAIL"){
showEmailError();
}else if(code == "DUPLICATE_USERNAME"){
showUsernameError();
}
}
}
const logout = async () =>{
store.dispatch("LOGOUT").then(()=>{
router.push({name: "Home"});
})
}
onMounted(async () => {
try {
const { data } = await getCurrentUser();
await store.dispatch("LOGIN", data.user)
getUser(data.user);
} catch (error: any) {
const code = error.response.data.errors.code;
if(code == "EMAIL_NULL_OR_INVALID"){
await router.push({name:"home"});
}
}
})
return {user, password, url, emailDuplicate, usernameDuplicate, getUser, setUser, logout};
}
}
</script>

View File

@@ -16,14 +16,6 @@ export default defineConfig({
server: {
https: true,
port: 4000,
proxy:{
'/': {
target : "http://3.35.44.58:8080",
rewrite: (path) => path.replace(/^\//,''),
changeOrigin: true,
secure: false
}
}
},
plugins: [vue(),basicSsl()]