Cleaned up UserService.java, refactored user page to use profile information instead of other endpoints.
This commit is contained in:
parent
346c5d9813
commit
bb5cf53908
|
@ -50,6 +50,7 @@ public class SecurityConfig {
|
||||||
"/submissions/**",
|
"/submissions/**",
|
||||||
"/auth/reset-password",
|
"/auth/reset-password",
|
||||||
"/auth/users/*",
|
"/auth/users/*",
|
||||||
|
"/auth/users/*/profile",
|
||||||
"/auth/users/*/access",
|
"/auth/users/*/access",
|
||||||
"/auth/users/*/followers",
|
"/auth/users/*/followers",
|
||||||
"/auth/users/*/following",
|
"/auth/users/*/following",
|
||||||
|
|
|
@ -2,6 +2,7 @@ package nl.andrewlalis.gymboard_api.domains.auth.controller;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.dto.*;
|
import nl.andrewlalis.gymboard_api.domains.auth.dto.*;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.UserPreferences;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.service.UserAccessService;
|
import nl.andrewlalis.gymboard_api.domains.auth.service.UserAccessService;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.service.UserService;
|
import nl.andrewlalis.gymboard_api.domains.auth.service.UserService;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
|
@ -36,6 +37,19 @@ public class UserController {
|
||||||
return userService.getUser(userId);
|
return userService.getUser(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all the information that the app will typically need to display a
|
||||||
|
* user's profile page. Some information may be omitted if the user has
|
||||||
|
* set their {@link UserPreferences#isAccountPrivate()}
|
||||||
|
* to true, and the requesting user isn't following them.
|
||||||
|
* @param userId The id of the user to fetch profile information for.
|
||||||
|
* @return The user's profile information.
|
||||||
|
*/
|
||||||
|
@GetMapping(path = "/auth/users/{userId}/profile")
|
||||||
|
public UserProfileResponse getUserProfile(@PathVariable String userId) {
|
||||||
|
return userService.getProfile(userId);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping(path = "/auth/users/{userId}/access")
|
@GetMapping(path = "/auth/users/{userId}/access")
|
||||||
public UserAccessResponse getUserAccess(@PathVariable String userId) {
|
public UserAccessResponse getUserAccess(@PathVariable String userId) {
|
||||||
return new UserAccessResponse(userAccessService.currentUserHasAccess(userId));
|
return new UserAccessResponse(userAccessService.currentUserHasAccess(userId));
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
||||||
|
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.UserFollowRequest;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.UserFollowRequest;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
@ -11,4 +12,6 @@ import java.time.LocalDateTime;
|
||||||
public interface UserFollowRequestRepository extends JpaRepository<UserFollowRequest, Long> {
|
public interface UserFollowRequestRepository extends JpaRepository<UserFollowRequest, Long> {
|
||||||
@Modifying
|
@Modifying
|
||||||
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
|
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
|
||||||
|
|
||||||
|
boolean existsByRequestingUserAndUserToFollowAndApprovedIsNull(User requestingUser, User userToFollow);
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,4 +20,6 @@ public interface UserFollowingRepository extends JpaRepository<UserFollowing, Lo
|
||||||
|
|
||||||
long countByFollowedUser(User followedUser);
|
long countByFollowedUser(User followedUser);
|
||||||
long countByFollowingUser(User followingUser);
|
long countByFollowingUser(User followingUser);
|
||||||
|
long countByFollowedUserId(String id);
|
||||||
|
long countByFollowingUserId(String id);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.domains.auth.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response that contains all the information needed to show a user's profile
|
||||||
|
* page.
|
||||||
|
*/
|
||||||
|
public record UserProfileResponse(
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
String followerCount,
|
||||||
|
String followingCount,
|
||||||
|
boolean followingThisUser,
|
||||||
|
boolean accountPrivate,
|
||||||
|
boolean canAccessThisUser
|
||||||
|
) {}
|
|
@ -0,0 +1,48 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.domains.auth.service;
|
||||||
|
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.dao.EmailResetCodeRepository;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.dao.PasswordResetCodeRepository;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserActivationCodeRepository;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserFollowRequestRepository;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.EmailResetCode;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.PasswordResetCode;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.UserActivationCode;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.UserFollowRequest;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CleanupService {
|
||||||
|
private final PasswordResetCodeRepository passwordResetCodeRepository;
|
||||||
|
private final UserActivationCodeRepository activationCodeRepository;
|
||||||
|
private final UserFollowRequestRepository followRequestRepository;
|
||||||
|
private final EmailResetCodeRepository emailResetCodeRepository;
|
||||||
|
|
||||||
|
public CleanupService(PasswordResetCodeRepository passwordResetCodeRepository, UserActivationCodeRepository activationCodeRepository, UserFollowRequestRepository followRequestRepository, EmailResetCodeRepository emailResetCodeRepository) {
|
||||||
|
this.passwordResetCodeRepository = passwordResetCodeRepository;
|
||||||
|
this.activationCodeRepository = activationCodeRepository;
|
||||||
|
this.followRequestRepository = followRequestRepository;
|
||||||
|
this.emailResetCodeRepository = emailResetCodeRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled task that periodically removes all old authentication entities
|
||||||
|
* so that they don't clutter up the system.
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.HOURS)
|
||||||
|
@Transactional
|
||||||
|
public void removeOldAuthEntities() {
|
||||||
|
LocalDateTime passwordResetCodeCutoff = LocalDateTime.now().minus(PasswordResetCode.VALID_FOR);
|
||||||
|
passwordResetCodeRepository.deleteAllByCreatedAtBefore(passwordResetCodeCutoff);
|
||||||
|
LocalDateTime activationCodeCutoff = LocalDateTime.now().minus(UserActivationCode.VALID_FOR);
|
||||||
|
activationCodeRepository.deleteAllByCreatedAtBefore(activationCodeCutoff);
|
||||||
|
LocalDateTime followRequestCutoff = LocalDateTime.now().minus(UserFollowRequest.VALID_FOR);
|
||||||
|
followRequestRepository.deleteAllByCreatedAtBefore(followRequestCutoff);
|
||||||
|
LocalDateTime emailResetCodeCutoff = LocalDateTime.now().minus(EmailResetCode.VALID_FOR);
|
||||||
|
emailResetCodeRepository.deleteAllByCreatedAtBefore(emailResetCodeCutoff);
|
||||||
|
}
|
||||||
|
}
|
|
@ -29,7 +29,7 @@ import java.time.LocalDateTime;
|
||||||
import java.util.Random;
|
import java.util.Random;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
import static nl.andrewlalis.gymboard_api.util.DataUtils.findByIdOrThrow;
|
import static nl.andrewlalis.gymboard_api.util.DataUtils.*;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class UserService {
|
public class UserService {
|
||||||
|
@ -283,23 +283,6 @@ public class UserService {
|
||||||
emailResetCodeRepository.delete(emailResetCode);
|
emailResetCodeRepository.delete(emailResetCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Scheduled task that periodically removes all old authentication entities
|
|
||||||
* so that they don't clutter up the system.
|
|
||||||
*/
|
|
||||||
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.HOURS)
|
|
||||||
@Transactional
|
|
||||||
public void removeOldAuthEntities() {
|
|
||||||
LocalDateTime passwordResetCodeCutoff = LocalDateTime.now().minus(PasswordResetCode.VALID_FOR);
|
|
||||||
passwordResetCodeRepository.deleteAllByCreatedAtBefore(passwordResetCodeCutoff);
|
|
||||||
LocalDateTime activationCodeCutoff = LocalDateTime.now().minus(UserActivationCode.VALID_FOR);
|
|
||||||
activationCodeRepository.deleteAllByCreatedAtBefore(activationCodeCutoff);
|
|
||||||
LocalDateTime followRequestCutoff = LocalDateTime.now().minus(UserFollowRequest.VALID_FOR);
|
|
||||||
followRequestRepository.deleteAllByCreatedAtBefore(followRequestCutoff);
|
|
||||||
LocalDateTime emailResetCodeCutoff = LocalDateTime.now().minus(EmailResetCode.VALID_FOR);
|
|
||||||
emailResetCodeRepository.deleteAllByCreatedAtBefore(emailResetCodeCutoff);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public UserPersonalDetailsResponse updatePersonalDetails(String id, UserPersonalDetailsPayload payload) {
|
public UserPersonalDetailsResponse updatePersonalDetails(String id, UserPersonalDetailsPayload payload) {
|
||||||
User user = userRepository.findById(id)
|
User user = userRepository.findById(id)
|
||||||
|
@ -366,12 +349,14 @@ public class UserService {
|
||||||
User followed = findByIdOrThrow(followedId, userRepository);
|
User followed = findByIdOrThrow(followedId, userRepository);
|
||||||
|
|
||||||
if (!userFollowingRepository.existsByFollowedUserAndFollowingUser(followed, follower)) {
|
if (!userFollowingRepository.existsByFollowedUserAndFollowingUser(followed, follower)) {
|
||||||
if (followed.getPreferences().isAccountPrivate()) {
|
if (!followed.getPreferences().isAccountPrivate()) {
|
||||||
userFollowingRepository.save(new UserFollowing(followed, follower));
|
userFollowingRepository.save(new UserFollowing(followed, follower));
|
||||||
return UserFollowResponse.requested();
|
|
||||||
} else {
|
|
||||||
followRequestRepository.save(new UserFollowRequest(follower, followed));
|
|
||||||
return UserFollowResponse.followed();
|
return UserFollowResponse.followed();
|
||||||
|
} else {
|
||||||
|
if (!followRequestRepository.existsByRequestingUserAndUserToFollowAndApprovedIsNull(follower, followed)) {
|
||||||
|
followRequestRepository.save(new UserFollowRequest(follower, followed));
|
||||||
|
}
|
||||||
|
return UserFollowResponse.requested();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return UserFollowResponse.alreadyFollowed();
|
return UserFollowResponse.alreadyFollowed();
|
||||||
|
@ -382,7 +367,6 @@ public class UserService {
|
||||||
if (followerId.equals(followedId)) return;
|
if (followerId.equals(followedId)) return;
|
||||||
User follower = findByIdOrThrow(followerId, userRepository);
|
User follower = findByIdOrThrow(followerId, userRepository);
|
||||||
User followed = findByIdOrThrow(followedId, userRepository);
|
User followed = findByIdOrThrow(followedId, userRepository);
|
||||||
|
|
||||||
userFollowingRepository.deleteByFollowedUserAndFollowingUser(followed, follower);
|
userFollowingRepository.deleteByFollowedUserAndFollowingUser(followed, follower);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -422,12 +406,14 @@ public class UserService {
|
||||||
.map(UserResponse::new);
|
.map(UserResponse::new);
|
||||||
}
|
}
|
||||||
|
|
||||||
public long getFollowerCount(String userId) {
|
public String getFollowerCount(String userId) {
|
||||||
return userFollowingRepository.countByFollowedUser(findByIdOrThrow(userId, userRepository));
|
long rawCount = userFollowingRepository.countByFollowedUserId(userId);
|
||||||
|
return formatLargeInt(fuzzInt(rawCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
public long getFollowingCount(String userId) {
|
public String getFollowingCount(String userId) {
|
||||||
return userFollowingRepository.countByFollowingUser(findByIdOrThrow(userId, userRepository));
|
long rawCount = userFollowingRepository.countByFollowingUserId(userId);
|
||||||
|
return formatLargeInt(fuzzInt(rawCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
|
@ -457,4 +443,23 @@ public class UserService {
|
||||||
payload.description()
|
payload.description()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public UserProfileResponse getProfile(String userId) {
|
||||||
|
User user = findByIdOrThrow(userId, userRepository);
|
||||||
|
boolean canAccessThisUser = userAccessService.currentUserHasAccess(user);
|
||||||
|
boolean followingThisUser = false;
|
||||||
|
if (SecurityContextHolder.getContext().getAuthentication() instanceof TokenAuthentication t) {
|
||||||
|
followingThisUser = userFollowingRepository.existsByFollowedUserAndFollowingUser(user, t.user());
|
||||||
|
}
|
||||||
|
return new UserProfileResponse(
|
||||||
|
user.getId(),
|
||||||
|
user.getName(),
|
||||||
|
getFollowerCount(user.getId()),
|
||||||
|
getFollowingCount(user.getId()),
|
||||||
|
followingThisUser,
|
||||||
|
user.getPreferences().isAccountPrivate(),
|
||||||
|
canAccessThisUser
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,4 +17,53 @@ public class DataUtils {
|
||||||
public static <T, ID> T findByIdOrThrow(ID id, CrudRepository<T, ID> repo) throws ResponseStatusException {
|
public static <T, ID> T findByIdOrThrow(ID id, CrudRepository<T, ID> repo) throws ResponseStatusException {
|
||||||
return repo.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
return repo.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a large integer value as a human-readable string with a size
|
||||||
|
* suffix. For example, instead of 1,245,300, we would return "1.2M".
|
||||||
|
* @param value The value to format.
|
||||||
|
* @return The string representation of the value.
|
||||||
|
*/
|
||||||
|
public static String formatLargeInt(long value) {
|
||||||
|
if (value < 1000) return Long.toString(value);
|
||||||
|
long scale = 1000;
|
||||||
|
final long MAX_SCALE = 1_000_000_000;
|
||||||
|
final char[] SCALE_SUFFIXES = {'K', 'M', 'B'};
|
||||||
|
int scaleSuffixIdx = 0;
|
||||||
|
|
||||||
|
while (scale <= MAX_SCALE) {
|
||||||
|
if (value < scale * 1000 || scale == MAX_SCALE) {
|
||||||
|
long baseValue = value / scale;
|
||||||
|
long remainderDigit = (value % scale) / (scale / 10);
|
||||||
|
StringBuilder sb = new StringBuilder(6);
|
||||||
|
sb.append(baseValue);
|
||||||
|
if (remainderDigit > 0) sb.append('.').append(remainderDigit);
|
||||||
|
sb.append(SCALE_SUFFIXES[scaleSuffixIdx]);
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
scale *= 1000;
|
||||||
|
scaleSuffixIdx++;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Fuzzes" an integer value such that it is randomly incremented or
|
||||||
|
* decremented by some amount proportional to its original value, so that
|
||||||
|
* its original true value is obscured slightly.
|
||||||
|
* @param value The value to fuzz.
|
||||||
|
* @return The fuzzed value.
|
||||||
|
*/
|
||||||
|
public static long fuzzInt(long value) {
|
||||||
|
double fuzz;
|
||||||
|
if (value < 1000) {
|
||||||
|
fuzz = 0.01;
|
||||||
|
} else if (value < 1_000_000) {
|
||||||
|
fuzz = 0.0001;
|
||||||
|
} else {
|
||||||
|
fuzz = 0.000001;
|
||||||
|
}
|
||||||
|
double modification = fuzz * Math.random() * value;
|
||||||
|
return value + Math.round(modification);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.util;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static nl.andrewlalis.gymboard_api.util.DataUtils.formatLargeInt;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
public class DataUtilsTest {
|
||||||
|
@Test
|
||||||
|
public void testFormatLargeInt() {
|
||||||
|
assertEquals("0", formatLargeInt(0));
|
||||||
|
assertEquals("1", formatLargeInt(1));
|
||||||
|
assertEquals("42", formatLargeInt(42));
|
||||||
|
assertEquals("999", formatLargeInt(999));
|
||||||
|
assertEquals("1K", formatLargeInt(1000));
|
||||||
|
assertEquals("1K", formatLargeInt(1099));
|
||||||
|
assertEquals("1.1K", formatLargeInt(1100));
|
||||||
|
assertEquals("1.1K", formatLargeInt(1199));
|
||||||
|
assertEquals("25.2K", formatLargeInt(25_231));
|
||||||
|
assertEquals("999K", formatLargeInt(999_000));
|
||||||
|
assertEquals("1M", formatLargeInt(1_000_000));
|
||||||
|
assertEquals("1.5M", formatLargeInt(1_500_000));
|
||||||
|
assertEquals("3B", formatLargeInt(3_000_000_000L));
|
||||||
|
assertEquals("1024B", formatLargeInt(1_024_000_000_000L));
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,4 +5,4 @@
|
||||||
|
|
||||||
echo "Starting gymboard-api development server."
|
echo "Starting gymboard-api development server."
|
||||||
./gen_keys.d
|
./gen_keys.d
|
||||||
./mvnw spring-boot:run -Dspring-boot.run.profiles=development
|
./mvnw clean spring-boot:run -Dspring-boot.run.profiles=development
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -49,6 +49,22 @@ export interface UserCreationPayload {
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
followerCount: string;
|
||||||
|
followingCount: string;
|
||||||
|
followingThisUser: boolean;
|
||||||
|
accountPrivate: boolean;
|
||||||
|
canAccessThisUser: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum UserFollowResponse {
|
||||||
|
FOLLOWED = 'FOLLOWED',
|
||||||
|
REQUESTED = 'REQUESTED',
|
||||||
|
ALREADY_FOLLOWED = 'ALREADY_FOLLOWED'
|
||||||
|
}
|
||||||
|
|
||||||
class AuthModule {
|
class AuthModule {
|
||||||
private static readonly TOKEN_REFRESH_INTERVAL_MS = 30000;
|
private static readonly TOKEN_REFRESH_INTERVAL_MS = 30000;
|
||||||
|
|
||||||
|
@ -115,6 +131,11 @@ class AuthModule {
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getUserProfile(userId: string, authStore: AuthStoreType): Promise<UserProfile> {
|
||||||
|
const response = await api.get(`/auth/users/${userId}/profile`, authStore.axiosConfig);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
public async isUserAccessible(
|
public async isUserAccessible(
|
||||||
userId: string,
|
userId: string,
|
||||||
authStore: AuthStoreType
|
authStore: AuthStoreType
|
||||||
|
@ -245,7 +266,7 @@ class AuthModule {
|
||||||
public async followUser(
|
public async followUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
authStore: AuthStoreType
|
authStore: AuthStoreType
|
||||||
): Promise<string> {
|
): Promise<UserFollowResponse> {
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
`/auth/users/${userId}/followers`,
|
`/auth/users/${userId}/followers`,
|
||||||
undefined,
|
undefined,
|
||||||
|
|
|
@ -21,8 +21,8 @@ import { useRoute, useRouter } from 'vue-router';
|
||||||
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
||||||
import { getGymFromRoute, getGymRoute } from 'src/router/gym-routing';
|
import { getGymFromRoute, getGymRoute } from 'src/router/gym-routing';
|
||||||
import { Gym } from 'src/api/main/gyms';
|
import { Gym } from 'src/api/main/gyms';
|
||||||
import PageMenu from "components/PageMenu.vue";
|
import PageMenu from 'components/PageMenu.vue';
|
||||||
import {useI18n} from "vue-i18n";
|
import {useI18n} from 'vue-i18n';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -39,14 +39,4 @@ onMounted(async () => {
|
||||||
await router.push('/');
|
await router.push('/');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const homePageSelected = computed(
|
|
||||||
() => gym.value && getGymRoute(gym.value) === route.fullPath
|
|
||||||
);
|
|
||||||
const submitPageSelected = computed(
|
|
||||||
() => gym.value && route.fullPath === getGymRoute(gym.value) + '/submit'
|
|
||||||
);
|
|
||||||
const leaderboardPageSelected = computed(
|
|
||||||
() => gym.value && route.fullPath === getGymRoute(gym.value) + '/leaderboard'
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -12,20 +12,20 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {User} from "src/api/main/auth";
|
import {User} from 'src/api/main/auth';
|
||||||
import {useAuthStore} from "stores/auth-store";
|
import {useAuthStore} from 'stores/auth-store';
|
||||||
import {onMounted, ref, Ref} from "vue";
|
import {onMounted, ref, Ref} from 'vue';
|
||||||
import api from 'src/api/main';
|
import api from 'src/api/main';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
userId: string
|
||||||
}
|
}
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const followers: Ref<User[]> = ref([]);
|
const followers: Ref<User[]> = ref([]);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
followers.value = await api.auth.getFollowers(props.user.id, authStore, 0, 10);
|
followers.value = await api.auth.getFollowers(props.userId, authStore, 0, 10);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@ import { onMounted, ref, Ref } from 'vue';
|
||||||
import api from 'src/api/main';
|
import api from 'src/api/main';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
userId: string;
|
||||||
}
|
}
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
@ -23,7 +23,7 @@ const following: Ref<User[]> = ref([]);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
following.value = await api.auth.getFollowing(
|
following.value = await api.auth.getFollowing(
|
||||||
props.user.id,
|
props.userId,
|
||||||
authStore,
|
authStore,
|
||||||
0,
|
0,
|
||||||
10
|
10
|
||||||
|
|
|
@ -1,15 +1,23 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page>
|
<q-page>
|
||||||
<StandardCenteredPage v-if="user">
|
<StandardCenteredPage v-if="profile">
|
||||||
<h3>{{ user?.name }}</h3>
|
<h3>{{ profile.name }}</h3>
|
||||||
|
<div v-if="devStore.showDebugInfo">
|
||||||
|
<div>Private: {{ profile.accountPrivate }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="relationship">
|
<div>
|
||||||
<q-btn v-if="!relationship.following" label="Follow" @click="followUser"/>
|
<div>Followers: {{ profile.followerCount }}</div>
|
||||||
<q-btn v-if="relationship.following" label="Unfollow" @click="unfollowUser"/>
|
<div>Following: {{ profile.followingCount }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="authStore.loggedIn && !isOwnUser">
|
||||||
|
<q-btn v-if="!profile.followingThisUser" label="Follow" @click="followUser"/>
|
||||||
|
<q-btn v-if="profile.followingThisUser" label="Unfollow" @click="unfollowUser"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PageMenu
|
<PageMenu
|
||||||
:base-route="`/users/${user.id}`"
|
:base-route="`/users/${profile.id}`"
|
||||||
:items="[
|
:items="[
|
||||||
{label: 'Lifts', to: ''},
|
{label: 'Lifts', to: ''},
|
||||||
{label: 'Followers', to: 'followers'},
|
{label: 'Followers', to: 'followers'},
|
||||||
|
@ -18,18 +26,21 @@
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Sub-pages are rendered here. -->
|
<!-- Sub-pages are rendered here. -->
|
||||||
<div v-if="userAccessible">
|
<div v-if="profile.canAccessThisUser">
|
||||||
<UserSubmissionsPage :user="user" v-if="route.path === getUserRoute(user)"/>
|
<UserSubmissionsPage :userId="profile.id" v-if="route.path === `/users/${profile.id}`"/>
|
||||||
<UserFollowersPage :user="user" v-if="route.path === getUserRoute(user) + '/followers'"/>
|
<UserFollowersPage :userId="profile.id" v-if="route.path === `/users/${profile.id}/followers`"/>
|
||||||
<UserFollowingPage :user="user" v-if="route.path === getUserRoute(user) + '/following'"/>
|
<UserFollowingPage :userId="profile.id" v-if="route.path === `/users/${profile.id}/following`"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!userAccessible">
|
<!-- If the user can't be accessed, show a placeholder message instead. -->
|
||||||
|
<div v-if="!profile.canAccessThisUser">
|
||||||
This account is private.
|
This account is private.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</StandardCenteredPage>
|
</StandardCenteredPage>
|
||||||
<StandardCenteredPage v-if="userNotFound">
|
|
||||||
|
<!-- If no user profile was loaded, we show a generic "User not found" message and no content. -->
|
||||||
|
<StandardCenteredPage v-if="!profile">
|
||||||
<h3>{{ $t('userPage.notFound.title') }}</h3>
|
<h3>{{ $t('userPage.notFound.title') }}</h3>
|
||||||
<p>{{ $t('userPage.notFound.description') }}</p>
|
<p>{{ $t('userPage.notFound.description') }}</p>
|
||||||
</StandardCenteredPage>
|
</StandardCenteredPage>
|
||||||
|
@ -39,36 +50,34 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
||||||
import {onMounted, ref, Ref, watch} from 'vue';
|
import {onMounted, ref, Ref, watch} from 'vue';
|
||||||
import {User, UserRelationship} from 'src/api/main/auth';
|
import {UserFollowResponse, UserProfile} from 'src/api/main/auth';
|
||||||
import api from 'src/api/main';
|
import api from 'src/api/main';
|
||||||
import {useRoute} from 'vue-router';
|
import {useRoute} from 'vue-router';
|
||||||
import {useAuthStore} from 'stores/auth-store';
|
import {useAuthStore} from 'stores/auth-store';
|
||||||
import {useI18n} from 'vue-i18n';
|
import {useI18n} from 'vue-i18n';
|
||||||
import {useQuasar} from 'quasar';
|
import {useQuasar} from 'quasar';
|
||||||
import {showApiErrorToast} from 'src/utils';
|
import {showApiErrorToast, showInfoToast} from 'src/utils';
|
||||||
import PageMenu from 'components/PageMenu.vue';
|
import PageMenu from 'components/PageMenu.vue';
|
||||||
import UserSubmissionsPage from 'pages/user/UserSubmissionsPage.vue';
|
import UserSubmissionsPage from 'pages/user/UserSubmissionsPage.vue';
|
||||||
import {getUserRoute} from 'src/router/user-routing';
|
|
||||||
import UserFollowersPage from 'pages/user/UserFollowersPage.vue';
|
import UserFollowersPage from 'pages/user/UserFollowersPage.vue';
|
||||||
import UserFollowingPage from 'pages/user/UserFollowingPage.vue';
|
import UserFollowingPage from 'pages/user/UserFollowingPage.vue';
|
||||||
|
import {useDevStore} from 'stores/dev-store';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
const devStore = useDevStore();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const quasar = useQuasar();
|
const quasar = useQuasar();
|
||||||
|
|
||||||
const user: Ref<User | undefined> = ref();
|
const profile: Ref<UserProfile | undefined> = ref();
|
||||||
const relationship: Ref<UserRelationship | undefined> = ref();
|
|
||||||
const isOwnUser = ref(false);
|
const isOwnUser = ref(false);
|
||||||
const userNotFound = ref(false);
|
|
||||||
const userAccessible = ref(false);
|
|
||||||
|
|
||||||
// If the user id changes, we have to manually reload the new user, since we
|
// If the user id changes, we have to manually reload the new user, since we
|
||||||
// will end up on the same route component, which means the router won't
|
// will end up on the same route component, which means the router won't
|
||||||
// re-render.
|
// re-render.
|
||||||
watch(route, async (updatedRoute) => {
|
watch(route, async (updatedRoute) => {
|
||||||
const userId = updatedRoute.params.userId[0];
|
const userId = updatedRoute.params.userId[0];
|
||||||
if (!user.value || user.value.id !== userId) {
|
if (!profile.value || (profile.value.id !== userId)) {
|
||||||
await loadUser(userId);
|
await loadUser(userId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -80,41 +89,24 @@ onMounted(async () => {
|
||||||
|
|
||||||
async function loadUser(id: string) {
|
async function loadUser(id: string) {
|
||||||
try {
|
try {
|
||||||
user.value = await api.auth.getUser(id, authStore);
|
profile.value = await api.auth.getUserProfile(id, authStore);
|
||||||
isOwnUser.value = authStore.loggedIn && user.value.id === authStore.user?.id;
|
isOwnUser.value = authStore.loggedIn && profile.value.id === authStore.user?.id;
|
||||||
userAccessible.value = await api.auth.isUserAccessible(id, authStore);
|
|
||||||
await loadRelationship();
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
user.value = undefined;
|
if (!(error.response && error.response.status === 404)) {
|
||||||
relationship.value = undefined;
|
|
||||||
isOwnUser.value = false;
|
|
||||||
userAccessible.value = false;
|
|
||||||
if (error.response && error.response.status === 404) {
|
|
||||||
userNotFound.value = true;
|
|
||||||
} else {
|
|
||||||
showApiErrorToast(i18n, quasar);
|
showApiErrorToast(i18n, quasar);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRelationship() {
|
|
||||||
if (authStore.user && user.value && userAccessible.value && authStore.user.id !== user.value.id) {
|
|
||||||
try {
|
|
||||||
relationship.value = await api.auth.getRelationshipTo(authStore.user.id, user.value.id, authStore);
|
|
||||||
} catch (error) {
|
|
||||||
relationship.value = undefined;
|
|
||||||
showApiErrorToast(i18n, quasar);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
relationship.value = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function followUser() {
|
async function followUser() {
|
||||||
if (user.value) {
|
if (profile.value && !profile.value.followingThisUser) {
|
||||||
try {
|
try {
|
||||||
await api.auth.followUser(user.value?.id, authStore);
|
const result = await api.auth.followUser(profile.value.id, authStore);
|
||||||
await loadRelationship();
|
if (result === UserFollowResponse.FOLLOWED) {
|
||||||
|
await loadUser(profile.value.id);
|
||||||
|
} else if (result === UserFollowResponse.REQUESTED) {
|
||||||
|
showInfoToast(quasar, 'Requested to follow this user!');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showApiErrorToast(i18n, quasar);
|
showApiErrorToast(i18n, quasar);
|
||||||
}
|
}
|
||||||
|
@ -122,10 +114,10 @@ async function followUser() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unfollowUser() {
|
async function unfollowUser() {
|
||||||
if (user.value) {
|
if (profile.value && profile.value.followingThisUser) {
|
||||||
try {
|
try {
|
||||||
await api.auth.unfollowUser(user.value?.id, authStore);
|
await api.auth.unfollowUser(profile.value.id, authStore);
|
||||||
await loadRelationship();
|
await loadUser(profile.value.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showApiErrorToast(i18n, quasar);
|
showApiErrorToast(i18n, quasar);
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,13 +19,12 @@ import {useQuasar} from 'quasar';
|
||||||
import {useAuthStore} from 'stores/auth-store';
|
import {useAuthStore} from 'stores/auth-store';
|
||||||
import {onMounted, ref, Ref} from 'vue';
|
import {onMounted, ref, Ref} from 'vue';
|
||||||
import {ExerciseSubmission} from 'src/api/main/submission';
|
import {ExerciseSubmission} from 'src/api/main/submission';
|
||||||
import {User} from 'src/api/main/auth';
|
|
||||||
import api from 'src/api/main';
|
import api from 'src/api/main';
|
||||||
import ExerciseSubmissionListItem from 'components/ExerciseSubmissionListItem.vue';
|
import ExerciseSubmissionListItem from 'components/ExerciseSubmissionListItem.vue';
|
||||||
import {showApiErrorToast} from 'src/utils';
|
import {showApiErrorToast} from 'src/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
userId: string;
|
||||||
}
|
}
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
@ -36,7 +35,7 @@ const authStore = useAuthStore();
|
||||||
const recentSubmissions: Ref<ExerciseSubmission[]> = ref([]);
|
const recentSubmissions: Ref<ExerciseSubmission[]> = ref([]);
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
recentSubmissions.value = await api.users.getRecentSubmissions(props.user.id, authStore);
|
recentSubmissions.value = await api.users.getRecentSubmissions(props.userId, authStore);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
showApiErrorToast(i18n, quasar);
|
showApiErrorToast(i18n, quasar);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
/**
|
||||||
|
* This store keeps track of global state that's only relevant for development
|
||||||
|
* purposes, like debug flags and settings.
|
||||||
|
*/
|
||||||
|
import {defineStore} from 'pinia';
|
||||||
|
|
||||||
|
interface DevState {
|
||||||
|
showDebugInfo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDevStore = defineStore('devStore', {
|
||||||
|
state: (): DevState => {
|
||||||
|
return { showDebugInfo: !!process.env.DEV };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DevStoreType = ReturnType<typeof useDevStore>;
|
|
@ -1,4 +1,4 @@
|
||||||
import {QVueGlobals} from "quasar";
|
import {QVueGlobals} from 'quasar';
|
||||||
|
|
||||||
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
@ -9,3 +9,11 @@ export function showApiErrorToast(i18n: any, quasar: QVueGlobals) {
|
||||||
position: 'top'
|
position: 'top'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function showInfoToast(quasar: QVueGlobals, translatedMessage: string) {
|
||||||
|
quasar.notify({
|
||||||
|
message: translatedMessage,
|
||||||
|
type: 'info',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue