diff --git a/.github/workflows/be-merge-prod.yml b/.github/workflows/be-merge-prod.yml index 2a716106..445318cf 100644 --- a/.github/workflows/be-merge-prod.yml +++ b/.github/workflows/be-merge-prod.yml @@ -52,7 +52,7 @@ jobs: - name: Docker Image Build working-directory: backend - run: docker build --platform linux/arm64/v8 -t mapbefine/mapbefine -f Dockerfile-dev . + run: docker build --platform linux/arm64/v8 -t mapbefine/mapbefine -f Dockerfile-prod . - name: Docker Hub Push run: docker push mapbefine/mapbefine diff --git a/.github/workflows/fe-merge-dev.yml b/.github/workflows/fe-merge-dev.yml index 3f2a243c..f53fb4a0 100644 --- a/.github/workflows/fe-merge-dev.yml +++ b/.github/workflows/fe-merge-dev.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: pull_request: - branches: [develop-FE-2] + branches: [develop-FE] types: [closed] paths: frontend/** @@ -35,6 +35,7 @@ jobs: working-directory: frontend env: REACT_APP_GOOGLE_ANALYTICS: ${{ secrets.REACT_APP_GOOGLE_ANALYTICS }} + REACT_APP_TMAP_API_KEY: ${{ secrets.REACT_APP_TMAP_API_KEY }} APP_URL: "https://mapbefine.kro.kr/api" - name: upload to artifact diff --git a/.github/workflows/fe-merge-prod.yml b/.github/workflows/fe-merge-prod.yml index 9a824c97..d550779e 100644 --- a/.github/workflows/fe-merge-prod.yml +++ b/.github/workflows/fe-merge-prod.yml @@ -35,6 +35,7 @@ jobs: working-directory: frontend env: REACT_APP_GOOGLE_ANALYTICS: ${{ secrets.REACT_APP_GOOGLE_ANALYTICS }} + REACT_APP_TMAP_API_KEY: ${{ secrets.REACT_APP_TMAP_API_KEY }} APP_URL: "https://mapbefine.com/api" - name: upload to artifact diff --git a/.github/workflows/fe-pull-request.yml b/.github/workflows/fe-pull-request.yml index b827458d..909a0170 100644 --- a/.github/workflows/fe-pull-request.yml +++ b/.github/workflows/fe-pull-request.yml @@ -3,7 +3,7 @@ name: Frontend CI For Test Validation on: # pull request open과 reopen 시 실행한다. pull_request: - branches: [main, develop-FE-2] + branches: [main, develop-FE] paths: frontend/** defaults: diff --git a/backend/Dockerfile-prod b/backend/Dockerfile-prod index 6ba556c1..1c0570e2 100644 --- a/backend/Dockerfile-prod +++ b/backend/Dockerfile-prod @@ -1,3 +1,3 @@ -FROM openjdk:17 -COPY build/libs/mapbefine.jar mapbefine.jar +FROM openjdk:17 +COPY build/libs/mapbefine.jar mapbefine.jar ENTRYPOINT ["java", "-jar","-Dspring.profiles.active=prod", "mapbefine.jar"] diff --git a/backend/src/docs/asciidoc/topic.adoc b/backend/src/docs/asciidoc/topic.adoc index 509e4716..9fcadff1 100644 --- a/backend/src/docs/asciidoc/topic.adoc +++ b/backend/src/docs/asciidoc/topic.adoc @@ -43,3 +43,6 @@ operation::topic-controller-test/update[snippets='http-request,http-response'] === 토픽 이미지 수정 operation::topic-controller-test/update-image[snippets='http-request,http-response'] + +=== 토픽 클러스터링 조회 +operation::topic-controller-test/get-clusters-of-pins[snippets='http-request,http-response'] \ No newline at end of file diff --git a/backend/src/main/java/com/mapbefine/mapbefine/admin/application/AdminCommandService.java b/backend/src/main/java/com/mapbefine/mapbefine/admin/application/AdminCommandService.java index 989a79f0..05119b27 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/admin/application/AdminCommandService.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/admin/application/AdminCommandService.java @@ -117,6 +117,7 @@ public void deletePin(Long pinId) { .orElseThrow(() -> new PinNotFoundException(PIN_NOT_FOUND, pinId)); pin.decreaseTopicPinCount(); + pinRepository.flush(); pinImageRepository.deleteAllByPinId(pinId); pinRepository.deleteById(pin.getId()); } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/location/domain/Coordinate.java b/backend/src/main/java/com/mapbefine/mapbefine/location/domain/Coordinate.java index 1ec913d6..26e107e3 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/location/domain/Coordinate.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/location/domain/Coordinate.java @@ -21,6 +21,8 @@ public class Coordinate { private static final double LATITUDE_UPPER_BOUND = 43; private static final double LONGITUDE_LOWER_BOUND = 124; private static final double LONGITUDE_UPPER_BOUND = 132; + private static final double EARTH_RADIUS = 6371.0; + private static final int CONVERT_TO_METER = 1000; /* * 4326은 데이터베이스에서 사용하는 여러 SRID 값 중, 일반적인 GPS기반의 위/경도 좌표를 저장할 때 쓰이는 값입니다. @@ -34,7 +36,6 @@ private Coordinate(Point point) { this.coordinate = point; } - public static Coordinate of(double latitude, double longitude) { validateRange(latitude, longitude); @@ -62,4 +63,13 @@ public double getLongitude() { return coordinate.getX(); } + public double calculateDistance(Coordinate coordinate) { + return Math.acos( + Math.sin(Math.toRadians(coordinate.getLatitude())) * Math.sin(Math.toRadians(this.getLatitude())) + + (Math.cos(Math.toRadians(coordinate.getLatitude())) * Math.cos( + Math.toRadians(this.getLatitude())) * Math.cos( + Math.toRadians(coordinate.getLongitude() - this.getLongitude()))) + ) * EARTH_RADIUS * CONVERT_TO_METER; + } + } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/Pin.java b/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/Pin.java index 1229f070..c55c7975 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/Pin.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/Pin.java @@ -108,24 +108,6 @@ public void decreaseTopicPinCount() { topic.decreasePinCount(); } - public void copyToTopic(Member creator, Topic topic) { - Pin copiedPin = Pin.createPinAssociatedWithLocationAndTopicAndMember( - pinInfo.getName(), - pinInfo.getDescription(), - location, - topic, - creator - ); - - copyPinImages(copiedPin); - } - - private void copyPinImages(Pin pin) { - for (PinImage pinImage : pinImages) { - PinImage.createPinImageAssociatedWithPin(pinImage.getImageUrl(), pin); - } - } - public void addPinImage(PinImage pinImage) { pinImages.add(pinImage); } @@ -138,6 +120,10 @@ public double getLongitude() { return location.getLongitude(); } + public String getName() { + return pinInfo.getName(); + } + public String getDescription() { return pinInfo.getDescription(); } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/PinRepository.java b/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/PinRepository.java index 00827a6a..05b53d6d 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/PinRepository.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/PinRepository.java @@ -1,5 +1,6 @@ package com.mapbefine.mapbefine.pin.domain; +import com.mapbefine.mapbefine.pin.infrastructure.PinBatchRepositoryCustom; import java.util.List; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,11 +10,14 @@ import org.springframework.stereotype.Repository; @Repository -public interface PinRepository extends JpaRepository { +public interface PinRepository extends JpaRepository, PinBatchRepositoryCustom { @EntityGraph(attributePaths = {"location", "topic", "creator", "pinImages"}) List findAll(); + @EntityGraph(attributePaths = {"location", "topic", "creator", "pinImages"}) + List findAllByIdIn(List pinIds); + @EntityGraph(attributePaths = {"location", "topic", "creator", "pinImages"}) List findAllByTopicId(Long topicId); diff --git a/backend/src/main/java/com/mapbefine/mapbefine/pin/infrastructure/PinBatchRepositoryCustom.java b/backend/src/main/java/com/mapbefine/mapbefine/pin/infrastructure/PinBatchRepositoryCustom.java new file mode 100644 index 00000000..b1abb05e --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/pin/infrastructure/PinBatchRepositoryCustom.java @@ -0,0 +1,11 @@ +package com.mapbefine.mapbefine.pin.infrastructure; + +import com.mapbefine.mapbefine.pin.domain.Pin; +import com.mapbefine.mapbefine.topic.domain.Topic; +import java.util.List; + +public interface PinBatchRepositoryCustom { + + int[] saveAllToTopic(Topic topicForCopy, List originalPins); + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/pin/infrastructure/PinBatchRepositoryCustomImpl.java b/backend/src/main/java/com/mapbefine/mapbefine/pin/infrastructure/PinBatchRepositoryCustomImpl.java new file mode 100644 index 00000000..96da3f6b --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/pin/infrastructure/PinBatchRepositoryCustomImpl.java @@ -0,0 +1,142 @@ +package com.mapbefine.mapbefine.pin.infrastructure; + +import static java.sql.Statement.EXECUTE_FAILED; + +import com.mapbefine.mapbefine.pin.domain.Pin; +import com.mapbefine.mapbefine.pin.domain.PinImage; +import com.mapbefine.mapbefine.topic.domain.Topic; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.stream.IntStream; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Slf4j +@Repository +public class PinBatchRepositoryCustomImpl implements PinBatchRepositoryCustom { + + private final JdbcTemplate jdbcTemplate; + + public PinBatchRepositoryCustomImpl(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public int[] saveAllToTopic(Topic topicForCopy, List originalPins) { + int[] rowCount = bulkInsertPins(topicForCopy, originalPins); + List pinImageInsertDtos = createPinImageInsertDtos(originalPins, rowCount); + + if (pinImageInsertDtos.isEmpty()) { + return rowCount; + } + return bulkInsertPinImages(pinImageInsertDtos); + } + + private int[] bulkInsertPins(Topic topicForCopy, List originalPins) { + String bulkInsertSql = "INSERT INTO pin " + + "(name, description, member_id, topic_id, location_id, " + + "created_at, updated_at) " + + "VALUES " + + "(?, ?, ?, ?, ?, " + + "?, ?)"; + LocalDateTime createdAt = topicForCopy.getLastPinUpdatedAt(); + Long topicId = topicForCopy.getId(); + Long creatorId = topicForCopy.getCreator().getId(); + log.debug("[Query] bulk insert size {} : {}", originalPins.size(), bulkInsertSql); + + return jdbcTemplate.batchUpdate(bulkInsertSql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + Pin pin = originalPins.get(i); + ps.setString(1, pin.getName()); + ps.setString(2, pin.getDescription()); + ps.setLong(3, creatorId); + ps.setLong(4, topicId); + ps.setLong(5, pin.getLocation().getId()); + ps.setTimestamp(6, Timestamp.valueOf(createdAt)); + ps.setTimestamp(7, Timestamp.valueOf(createdAt)); + log.trace("[Parameter Binding] {} : " + + "name={}, description={}, member_id={}, topic_id={}, location_id={}, " + + "created_at={}, updated_at={}", + i, pin.getName(), pin.getDescription(), creatorId, topicId, pin.getLocation().getId(), + createdAt, createdAt); + } + + @Override + public int getBatchSize() { + return originalPins.size(); + } + }); + } + + private List createPinImageInsertDtos(List originalPins, int[] rowCount) { + Long firstIdFromBatch = jdbcTemplate.queryForObject("SELECT last_insert_id()", Long.class); + validateId(firstIdFromBatch); + + return IntStream.range(0, originalPins.size()) + .filter(index -> rowCount[index] != EXECUTE_FAILED) + .mapToObj(index -> { + Pin pin = originalPins.get(index); + return PinImageInsertDto.of(pin.getPinImages(), firstIdFromBatch + index); + }).flatMap(Collection::stream) + .toList(); + } + + private void validateId(Long firstIdFromBatch) { + if (Objects.isNull(firstIdFromBatch)) { + throw new IllegalStateException("fail to batch update pins"); + } + } + + private int[] bulkInsertPinImages(List pinImages) { + String bulkInsertSql = "INSERT INTO pin_image " + + "(image_url, pin_id) " + + "VALUES " + + "(?, ?)"; + log.debug("[Query] bulk insert size {} : {}", pinImages.size(), bulkInsertSql); + + return jdbcTemplate.batchUpdate(bulkInsertSql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + PinImageInsertDto pinImage = pinImages.get(i); + ps.setString(1, pinImage.imageUrl); + ps.setLong(2, pinImage.pinId); + log.trace("[Parameter Binding] {} : imageUrl={}, pinImage={} ", + i, pinImage.imageUrl, pinImage.pinId); + } + + @Override + public int getBatchSize() { + return pinImages.size(); + } + }); + } + + private record PinImageInsertDto( + String imageUrl, + Long pinId, + boolean isDeleted + ) { + + public static PinImageInsertDto of(PinImage pinImage, Long pinId) { + return new PinImageInsertDto( + pinImage.getImageUrl(), + pinId, + pinImage.isDeleted() + ); + } + + private static List of(List pinImages, Long pinId) { + return pinImages.stream() + .map(pinImage -> PinImageInsertDto.of(pinImage, pinId)) + .toList(); + } + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/application/TopicCommandService.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/application/TopicCommandService.java index a40117e9..e0a5c919 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/topic/application/TopicCommandService.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/application/TopicCommandService.java @@ -55,12 +55,11 @@ public Long saveTopic(AuthMember member, TopicCreateRequest request) { Topic topic = convertToTopic(member, request); List pinIds = request.pins(); + topicRepository.save(topic); if (!pinIds.isEmpty()) { copyPinsToTopic(member, topic, pinIds); } - topicRepository.save(topic); - return topic.getId(); } @@ -105,14 +104,14 @@ private void copyPinsToTopic( ) { List originalPins = findAllPins(pinIds); validateCopyablePins(member, originalPins); + topic.increasePinCount(pinIds.size()); + pinRepository.flush(); - Member creator = findCreatorByAuthMember(member); - - originalPins.forEach(pin -> pin.copyToTopic(creator, topic)); + pinRepository.saveAllToTopic(topic, originalPins); } private List findAllPins(List pinIds) { - List findPins = pinRepository.findAllById(pinIds); + List findPins = pinRepository.findAllByIdIn(pinIds); if (pinIds.size() != findPins.size()) { throw new PinBadRequestException(ILLEGAL_PIN_ID); @@ -134,15 +133,13 @@ private void validateCopyablePins(AuthMember member, List originalPins) { public Long merge(AuthMember member, TopicMergeRequest request) { Topic topic = convertToTopic(member, request); List originalTopics = findAllTopics(request.topics()); - validateCopyableTopics(member, originalTopics); - - Member creator = findCreatorByAuthMember(member); List originalPins = getAllPinsFromTopics(originalTopics); - originalPins.forEach(pin -> pin.copyToTopic(creator, topic)); - topicRepository.save(topic); + topic.increasePinCount(originalPins.size()); + topicRepository.save(topic); + pinRepository.saveAllToTopic(topic, originalPins); return topic.getId(); } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/application/TopicQueryService.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/application/TopicQueryService.java index 053d9012..383a742d 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/topic/application/TopicQueryService.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/application/TopicQueryService.java @@ -8,8 +8,11 @@ import com.mapbefine.mapbefine.bookmark.domain.BookmarkRepository; import com.mapbefine.mapbefine.member.domain.Member; import com.mapbefine.mapbefine.member.domain.MemberRepository; +import com.mapbefine.mapbefine.pin.domain.Pin; +import com.mapbefine.mapbefine.topic.domain.Clusters; import com.mapbefine.mapbefine.topic.domain.Topic; import com.mapbefine.mapbefine.topic.domain.TopicRepository; +import com.mapbefine.mapbefine.topic.dto.response.ClusterResponse; import com.mapbefine.mapbefine.topic.dto.response.TopicDetailResponse; import com.mapbefine.mapbefine.topic.dto.response.TopicResponse; import com.mapbefine.mapbefine.topic.exception.TopicException.TopicForbiddenException; @@ -241,4 +244,24 @@ private List getUserBestTopicResponse(AuthMember authMember) { )).toList(); } + public List findClustersPinsByIds( + AuthMember authMember, + List topicIds, + Double imageDiameter + ) { + List topics = topicRepository.findByIdIn(topicIds); + topics.forEach(topic -> validateReadableTopic(authMember, topic)); + + List allPins = topics.stream() + .map(Topic::getPins) + .flatMap(List::stream) + .toList(); + + return Clusters.from(allPins, imageDiameter) + .getClusters() + .stream() + .map(ClusterResponse::from) + .toList(); + } + } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/Cluster.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/Cluster.java new file mode 100644 index 00000000..be25ae5b --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/Cluster.java @@ -0,0 +1,36 @@ +package com.mapbefine.mapbefine.topic.domain; + +import com.mapbefine.mapbefine.pin.domain.Pin; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import lombok.Getter; + +@Getter +public class Cluster { + + private final double latitude; + private final double longitude; + private final List pins; + + private Cluster(double latitude, double longitude, List pins) { + this.latitude = latitude; + this.longitude = longitude; + this.pins = pins; + } + + public static Cluster from(Pin representPin, List pins) { + return new Cluster(representPin.getLatitude(), representPin.getLongitude(), rearrangePins(representPin, pins)); + } + + private static List rearrangePins(Pin representPin, List pins) { + List arrangePins = new ArrayList<>(List.of(representPin)); + + pins.stream() + .filter(pin -> !Objects.equals(representPin, pin)) + .forEach(arrangePins::add); + + return arrangePins; + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/Clusters.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/Clusters.java new file mode 100644 index 00000000..f762ea03 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/Clusters.java @@ -0,0 +1,135 @@ +package com.mapbefine.mapbefine.topic.domain; + +import com.mapbefine.mapbefine.location.domain.Coordinate; +import com.mapbefine.mapbefine.pin.domain.Pin; +import com.mapbefine.mapbefine.topic.exception.TopicErrorCode; +import com.mapbefine.mapbefine.topic.exception.TopicException.TopicBadRequestException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import lombok.Getter; + +@Getter +public class Clusters { + + private final List clusters; + + private Clusters(List clusters) { + this.clusters = clusters; + } + + public static Clusters from(List pins, Double diameterInMeter) { + validateDiameter(diameterInMeter); + List clusters = executeCluster(pins, diameterInMeter); + + return new Clusters(clusters); + } + + private static void validateDiameter(Double diameterInMeter) { + if (Objects.nonNull(diameterInMeter)) { + return; + } + + throw new TopicBadRequestException(TopicErrorCode.ILLEGAL_DIAMETER_NULL); + } + + private static List executeCluster(List pins, double diameterInMeter) { + List parentOfPins = getParentOfPins(pins, diameterInMeter); + + return getClustersByParentOfPins(pins, parentOfPins); + } + + private static List getParentOfPins(List pins, double diameterInMeter) { + List parentOfPins = getInitializeParentOfPins(pins.size()); + + for (int i = 0; i < pins.size(); i++) { + for (int j = 0; j < pins.size(); j++) { + if (isNotRepresentPin(parentOfPins, i)) { + break; + } + + if (i == j) { + continue; + } + + int firstParentPinIndex = findParentOfSet(parentOfPins, i); + int secondParentPinIndex = findParentOfSet(parentOfPins, j); + + if (isReachTwoPin(pins.get(firstParentPinIndex), pins.get(secondParentPinIndex), diameterInMeter)) { + union(parentOfPins, firstParentPinIndex, secondParentPinIndex, pins); + } + } + } + + return parentOfPins; + } + + private static boolean isNotRepresentPin(List parentOfPins, int i) { + return parentOfPins.get(i) != i; + } + + private static List getInitializeParentOfPins(int pinsSize) { + List parentOfPins = new ArrayList<>(); + + for (int i = 0; i < pinsSize; i++) { + parentOfPins.add(i); + } + + return parentOfPins; + } + + private static boolean isReachTwoPin(Pin firstPin, Pin secondPin, double diameterInMeter) { + Coordinate firstPinCoordinate = firstPin.getLocation().getCoordinate(); + Coordinate secondPinCoordinate = secondPin.getLocation().getCoordinate(); + + return firstPinCoordinate.calculateDistance(secondPinCoordinate) <= diameterInMeter; + } + + private static int findParentOfSet(List parentOfPins, int pinIndex) { + if (parentOfPins.get(pinIndex) == pinIndex) { + return pinIndex; + } + + parentOfPins.set(pinIndex, findParentOfSet(parentOfPins, parentOfPins.get(pinIndex))); + return parentOfPins.get(pinIndex); + } + + private static void union(List parentOfPins, int firstPinIndex, int secondPinIndex, List pins) { + if (firstPinIndex == secondPinIndex) { + return; + } + Pin firstPin = pins.get(firstPinIndex); + Pin secondPin = pins.get(secondPinIndex); + if (isFirstPinOnLeft(firstPin, secondPin)) { + parentOfPins.set(secondPinIndex, firstPinIndex); + return; + } + parentOfPins.set(firstPinIndex, secondPinIndex); + } + + private static boolean isFirstPinOnLeft(Pin firstPin, Pin secondPin) { + Coordinate firstPinCoordinate = firstPin.getLocation().getCoordinate(); + Coordinate secondPinCoordinate = secondPin.getLocation().getCoordinate(); + + return firstPinCoordinate.getLongitude() < secondPinCoordinate.getLongitude(); + } + + private static List getClustersByParentOfPins(List pins, List parentOfPins) { + Map> clusters = new HashMap<>(); + + for (int pinIndex = 0; pinIndex < pins.size(); pinIndex++) { + int parentPinIndex = findParentOfSet(parentOfPins, pinIndex); + Pin parentPin = pins.get(parentPinIndex); + clusters.computeIfAbsent(parentPin, ignored -> new ArrayList<>()); + clusters.get(parentPin).add(pins.get(pinIndex)); + } + + return clusters.entrySet() + .stream() + .map(clustersEntry -> Cluster.from(clustersEntry.getKey(), clustersEntry.getValue())) + .toList(); + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/Topic.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/Topic.java index 896c7064..eaaac884 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/Topic.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/Topic.java @@ -152,6 +152,10 @@ public void removeImage() { this.topicInfo = topicInfo.removeImage(); } + public void increasePinCount(int count) { + pinCount += count; + } + public void decreasePinCount() { pinCount--; } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/response/ClusterResponse.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/response/ClusterResponse.java new file mode 100644 index 00000000..93b2739d --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/response/ClusterResponse.java @@ -0,0 +1,23 @@ +package com.mapbefine.mapbefine.topic.dto.response; + +import com.mapbefine.mapbefine.topic.domain.Cluster; +import java.util.List; + +public record ClusterResponse( + double latitude, + double longitude, + List pins +) { + + public static ClusterResponse from(Cluster cluster) { + return new ClusterResponse( + cluster.getLatitude(), + cluster.getLongitude(), + cluster.getPins() + .stream() + .map(RenderPinResponse::from) + .toList() + ); + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/response/RenderPinResponse.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/response/RenderPinResponse.java new file mode 100644 index 00000000..48862f1d --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/response/RenderPinResponse.java @@ -0,0 +1,19 @@ +package com.mapbefine.mapbefine.topic.dto.response; + +import com.mapbefine.mapbefine.pin.domain.Pin; + +public record RenderPinResponse( + Long id, + String name, + Long topicId +) { + + public static RenderPinResponse from(Pin pin) { + return new RenderPinResponse( + pin.getId(), + pin.getPinInfo().getName(), + pin.getTopic().getId() + ); + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/exception/TopicErrorCode.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/exception/TopicErrorCode.java index 4ae8d3a3..2f0e1bcb 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/topic/exception/TopicErrorCode.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/exception/TopicErrorCode.java @@ -14,6 +14,7 @@ public enum TopicErrorCode { ILLEGAL_PERMISSION_FOR_PUBLICITY_PRIVATE("09007", "비공개 지도인 경우, 권한 설정이 소속 회원이어야합니다."), ILLEGAL_PUBLICITY_FOR_PERMISSION_ALL_MEMBERS("09008", "권한 범위가 모든 회원인 경우, 비공개 지도로 설정할 수 없습니다."), ILLEGAL_PERMISSION_UPDATE("09009", "권한 범위를 모든 회원에서 소속 회원으로 수정할 수 없습니다."), + ILLEGAL_DIAMETER_NULL("09010", "이미지의 실제 크기는 필수로 입력해야합니다."), FORBIDDEN_TOPIC_CREATE("09300", "로그인하지 않은 사용자는 지도를 생성할 수 없습니다."), FORBIDDEN_TOPIC_UPDATE("09301", "지도 수정 권한이 없습니다."), FORBIDDEN_TOPIC_DELETE("09302", "지도 삭제 권한이 없습니다."), diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/presentation/TopicController.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/presentation/TopicController.java index 1f833155..a1b367de 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/topic/presentation/TopicController.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/presentation/TopicController.java @@ -2,6 +2,7 @@ import com.mapbefine.mapbefine.auth.domain.AuthMember; import com.mapbefine.mapbefine.common.interceptor.LoginRequired; +import com.mapbefine.mapbefine.topic.dto.response.ClusterResponse; import com.mapbefine.mapbefine.topic.application.TopicCommandService; import com.mapbefine.mapbefine.topic.application.TopicQueryService; import com.mapbefine.mapbefine.topic.dto.request.TopicCreateRequest; @@ -146,6 +147,21 @@ public ResponseEntity> findAllBestTopics(AuthMember authMemb return ResponseEntity.ok(responses); } + @GetMapping("/clusters") + public ResponseEntity> getClustersOfPins( + AuthMember authMember, + @RequestParam("ids") List topicIds, + @RequestParam("image-diameter") Double imageDiameter + ) { + List responses = topicQueryService.findClustersPinsByIds( + authMember, + topicIds, + imageDiameter + ); + + return ResponseEntity.ok(responses); + } + @LoginRequired @PutMapping( value = "/images/{topicId}", diff --git a/backend/src/main/resources/config b/backend/src/main/resources/config index d7b073fa..ee3bb3d3 160000 --- a/backend/src/main/resources/config +++ b/backend/src/main/resources/config @@ -1 +1 @@ -Subproject commit d7b073fabc2dcf0a9bdb40424917a734cc225985 +Subproject commit ee3bb3d39da4985f7a4e2f1b1cc255bf4a1a92e4 diff --git a/backend/src/test/java/com/mapbefine/mapbefine/TestDatabaseContainer.java b/backend/src/test/java/com/mapbefine/mapbefine/TestDatabaseContainer.java index 271cdc7f..eda36ad1 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/TestDatabaseContainer.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/TestDatabaseContainer.java @@ -15,11 +15,15 @@ public abstract class TestDatabaseContainer { @DynamicPropertySource public static void overrideProps(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", mySQLContainer::getJdbcUrl); + registry.add("spring.datasource.url", TestDatabaseContainer::getJdbcUrlWithQueryStrings); registry.add("spring.datasource.username", mySQLContainer::getUsername); registry.add("spring.datasource.password", mySQLContainer::getPassword); registry.add("spring.datasource.driver-class-name", mySQLContainer::getDriverClassName); } + private static String getJdbcUrlWithQueryStrings() { + return mySQLContainer.getJdbcUrl() + "?rewriteBatchedStatements=true"; + } + } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/admin/AdminIntegrationTest.java b/backend/src/test/java/com/mapbefine/mapbefine/admin/AdminIntegrationTest.java index 109c9380..54eb8662 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/admin/AdminIntegrationTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/admin/AdminIntegrationTest.java @@ -1,6 +1,6 @@ package com.mapbefine.mapbefine.admin; -import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.*; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.http.HttpHeaders.AUTHORIZATION; @@ -23,7 +23,8 @@ import com.mapbefine.mapbefine.topic.TopicFixture; import com.mapbefine.mapbefine.topic.domain.Topic; import com.mapbefine.mapbefine.topic.domain.TopicRepository; -import io.restassured.common.mapper.TypeRef; +import io.restassured.common.mapper.*; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -32,8 +33,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; -import java.util.List; - class AdminIntegrationTest extends IntegrationTest { @Autowired @@ -124,15 +123,13 @@ void findMemberDetail_Success() { .extract() .as(new TypeRef<>() { }); - System.out.println("====" + topic.getPinCount()); - //then + //then AdminMemberDetailResponse expected = AdminMemberDetailResponse.of( member, member.getCreatedTopics(), member.getCreatedPins() ); - assertThat(response).usingRecursiveComparison() .ignoringFields("updatedAt") .ignoringFields("topics.updatedAt") @@ -218,6 +215,9 @@ void deletePin_Success() { .when().delete("/admin/pins/" + pin.getId()) .then().log().all() .statusCode(HttpStatus.NO_CONTENT.value()); + + Topic updatedTopic = topicRepository.findById(topic.getId()).get(); + assertThat(updatedTopic.getPinCount()).isEqualTo(0); } @Test diff --git a/backend/src/test/java/com/mapbefine/mapbefine/admin/application/AdminCommandServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/admin/application/AdminCommandServiceTest.java index 288eefda..5115f01a 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/admin/application/AdminCommandServiceTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/admin/application/AdminCommandServiceTest.java @@ -160,6 +160,20 @@ void deletePin_Success() { assertThat(pinRepository.existsById(pin.getId())).isFalse(); } + @DisplayName("핀 삭제 시, 토픽의 핀 개수를 1 감소시킨다.") + @Test + void deletePin_Success_decreaseTopicPinCount() { + //given + assertThat(pin.isDeleted()).isFalse(); + int pinCountBeforeDelete = topic.getPinCount(); + + //when + adminCommandService.deletePin(pin.getId()); + + //then + assertThat(topic.getPinCount()).isEqualTo(pinCountBeforeDelete - 1); + } + @DisplayName("Admin인 경우, 핀 이미지를 삭제할 수 있다.") @Test void deletePinImage_Success() { diff --git a/backend/src/test/java/com/mapbefine/mapbefine/common/config/MockBeansConfig.java b/backend/src/test/java/com/mapbefine/mapbefine/common/config/MockBeansConfig.java deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/src/test/java/com/mapbefine/mapbefine/pin/PinImageFixture.java b/backend/src/test/java/com/mapbefine/mapbefine/pin/PinImageFixture.java index 8b6f13c6..baf7eb0c 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/pin/PinImageFixture.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/pin/PinImageFixture.java @@ -5,8 +5,10 @@ public class PinImageFixture { + public static String IMAGE_URL = "https://example.com/image.jpg"; + public static PinImage create(Pin pin) { - return PinImage.createPinImageAssociatedWithPin("https://example.com/image.jpg", pin); + return PinImage.createPinImageAssociatedWithPin(IMAGE_URL, pin); } } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/pin/domain/PinRepositoryTest.java b/backend/src/test/java/com/mapbefine/mapbefine/pin/domain/PinRepositoryTest.java index 0d60bf28..00490919 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/pin/domain/PinRepositoryTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/pin/domain/PinRepositoryTest.java @@ -1,27 +1,27 @@ package com.mapbefine.mapbefine.pin.domain; +import static com.mapbefine.mapbefine.member.domain.Role.USER; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; import com.mapbefine.mapbefine.TestDatabaseContainer; import com.mapbefine.mapbefine.common.annotation.RepositoryTest; -import com.mapbefine.mapbefine.common.config.JpaConfig; import com.mapbefine.mapbefine.location.LocationFixture; import com.mapbefine.mapbefine.location.domain.Location; import com.mapbefine.mapbefine.location.domain.LocationRepository; import com.mapbefine.mapbefine.member.MemberFixture; import com.mapbefine.mapbefine.member.domain.Member; import com.mapbefine.mapbefine.member.domain.MemberRepository; -import com.mapbefine.mapbefine.member.domain.Role; import com.mapbefine.mapbefine.pin.PinFixture; +import com.mapbefine.mapbefine.pin.PinImageFixture; import com.mapbefine.mapbefine.topic.TopicFixture; import com.mapbefine.mapbefine.topic.domain.Topic; import com.mapbefine.mapbefine.topic.domain.TopicRepository; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.context.annotation.Import; @RepositoryTest class PinRepositoryTest extends TestDatabaseContainer { @@ -31,6 +31,8 @@ class PinRepositoryTest extends TestDatabaseContainer { @Autowired private PinRepository pinRepository; @Autowired + private PinImageRepository pinImageRepository; + @Autowired private LocationRepository locationRepository; @Autowired private MemberRepository memberRepository; @@ -41,7 +43,7 @@ class PinRepositoryTest extends TestDatabaseContainer { @BeforeEach void setUp() { - member = memberRepository.save(MemberFixture.create("member", "member@gmail.com", Role.USER)); + member = memberRepository.save(MemberFixture.create("member", "member@gmail.com", USER)); topic = topicRepository.save(TopicFixture.createByName("topic", member)); location = locationRepository.save(LocationFixture.create()); } @@ -123,4 +125,39 @@ void deleteAllByMemberIdInOtherTopics_Success() { assertThat(pinRepository.findAllByCreatorId(MemberId)).isEmpty(); } + @Test + @DisplayName("기존에 존재하는 핀들을 토픽에 한 번에 복사할 수 있다. (bulk insert)") + void saveAllToTopic() { + // given + for (int i = 0; i < 10; i++) { + Pin pin = pinRepository.save(PinFixture.create(location, topic, member)); + pinRepository.flush(); + pinImageRepository.save(PinImageFixture.create(pin)); + } + Member copier = memberRepository.save(MemberFixture.create("copier", "copier@gmail.com", USER)); + Topic topicForCopy = topicRepository.save(TopicFixture.createByName("otherTopic", copier)); + + // when + List originalPins = topic.getPins(); + pinRepository.saveAllToTopic(topicForCopy, originalPins); + + // then + List copiedPins = pinRepository.findAllByTopicId(topicForCopy.getId()); + List originalPinInfos = originalPins.stream() + .map(Pin::getPinInfo) + .toList(); + + assertSoftly(softly -> { + softly.assertThat(copiedPins).extracting("pinInfo") + .usingRecursiveComparison() + .isEqualTo(originalPinInfos); + softly.assertThat(copiedPins.get(0).getCreator()) + .isEqualTo(copier); + softly.assertThat(copiedPins).hasSize(originalPins.size()) + .flatMap(Pin::getPinImages) + .allSatisfy(pinImage -> { + assertThat(pinImage.getImageUrl()).isEqualTo(PinImageFixture.IMAGE_URL); + }); + }); + } } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/pin/domain/PinTest.java b/backend/src/test/java/com/mapbefine/mapbefine/pin/domain/PinTest.java index c9d18f51..91a9790c 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/pin/domain/PinTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/pin/domain/PinTest.java @@ -9,14 +9,12 @@ import com.mapbefine.mapbefine.member.domain.Role; import com.mapbefine.mapbefine.topic.TopicFixture; import com.mapbefine.mapbefine.topic.domain.Topic; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import java.util.List; -import java.util.Set; - class PinTest { @@ -64,35 +62,6 @@ void createPinAssociatedWithLocationAndTopicAndMember_associate_Success() { .isEqualTo(pin); } - @Test - @DisplayName("Pin을 복사하면 Topic, Member 외 정보가 모두 같은 새로운 Pin을 생성해 반환한다.") - void copy_Success() { - // given - Pin original = Pin.createPinAssociatedWithLocationAndTopicAndMember( - "before name", - "before description", - location, - topic, - member - ); - PinImage.createPinImageAssociatedWithPin("https://example.com/image.jpg", original); - Member memberForCopy = MemberFixture.create("복사해 갈 회원", "other@gmail.com", Role.USER); - Topic topicForCopy = TopicFixture.createByName("복사해 갈 토픽 이름", memberForCopy); - - // when - original.copyToTopic(memberForCopy, topicForCopy); - Pin actual = topicForCopy.getPins().iterator().next(); - - // then - assertThat(original).usingRecursiveComparison() - .ignoringFieldsOfTypes(Topic.class, Member.class) - .isEqualTo(actual); - assertThat(actual.getTopic()).usingRecursiveComparison() - .isEqualTo(topicForCopy); - assertThat(actual.getCreator()).usingRecursiveComparison() - .isEqualTo(memberForCopy); - } - @Nested class Validate { diff --git a/backend/src/test/java/com/mapbefine/mapbefine/topic/TopicIntegrationTest.java b/backend/src/test/java/com/mapbefine/mapbefine/topic/TopicIntegrationTest.java index 7b7a53aa..de332a41 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/topic/TopicIntegrationTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/topic/TopicIntegrationTest.java @@ -25,9 +25,11 @@ import com.mapbefine.mapbefine.topic.dto.request.TopicUpdateRequest; import com.mapbefine.mapbefine.topic.dto.response.TopicDetailResponse; import com.mapbefine.mapbefine.topic.dto.response.TopicResponse; -import io.restassured.RestAssured; -import io.restassured.response.ExtractableResponse; -import io.restassured.response.Response; +import io.restassured.*; +import io.restassured.response.*; +import java.io.File; +import java.util.Collections; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -35,10 +37,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; -import java.io.File; -import java.util.Collections; -import java.util.List; - class TopicIntegrationTest extends IntegrationTest { @Autowired @@ -106,7 +104,10 @@ private ExtractableResponse createNewTopic(TopicCreateRequestWithoutIm .extract(); } - private ExtractableResponse createNewTopicExcludeImage(TopicCreateRequestWithoutImage request, String authHeader) { + private ExtractableResponse createNewTopicExcludeImage( + TopicCreateRequestWithoutImage request, + String authHeader + ) { return RestAssured.given() .log().all() .header(AUTHORIZATION, authHeader) @@ -119,19 +120,13 @@ private ExtractableResponse createNewTopicExcludeImage(TopicCreateRequ @Test @DisplayName("Pin 목록과 함께 Topic을 생성하면 201을 반환한다") void createNewTopicWithPins_Success() { - PinFixture.create(location, topic, member); - - List pins = pinRepository.findAll(); - List pinIds = pins.stream() - .map(Pin::getId) - .toList(); - + Pin pin = pinRepository.save(PinFixture.create(location, topic, member)); TopicCreateRequestWithoutImage 준팍의_또간집 = new TopicCreateRequestWithoutImage( "준팍의 또간집", "준팍이 2번 이상 간집 ", Publicity.PUBLIC, PermissionType.ALL_MEMBERS, - pinIds + List.of(pin.getId()) ); // when @@ -545,4 +540,33 @@ void updateTopicImage_Success() { assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); } + @Test + @DisplayName("Topic의 클러스터링 된 핀들을 조회할 때 200을 반환한다.") + void getClustersOfPins() { + ExtractableResponse newTopic = createNewTopic( + new TopicCreateRequestWithoutImage( + "매튜의 헬스장", + "맛있는 음식들이 즐비한 헬스장!", + Publicity.PUBLIC, + PermissionType.ALL_MEMBERS, + Collections.emptyList() + ), + authHeader + ); + long topicId = Long.parseLong(newTopic.header("Location").split("/")[2]); + + // when + ExtractableResponse response = RestAssured + .given().log().all() + .header(AUTHORIZATION, authHeader) + .param("ids", List.of(topicId)) + .param("image-diameter", 1) + .when().get("/topics/clusters") + .then().log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + } + } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/topic/application/TopicCommandServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/topic/application/TopicCommandServiceTest.java index 1d65e3dc..b66fdce1 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/topic/application/TopicCommandServiceTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/topic/application/TopicCommandServiceTest.java @@ -1,7 +1,6 @@ package com.mapbefine.mapbefine.topic.application; import static com.mapbefine.mapbefine.image.FileFixture.createFile; -import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; @@ -11,7 +10,6 @@ import com.mapbefine.mapbefine.auth.domain.member.Admin; import com.mapbefine.mapbefine.auth.domain.member.Guest; import com.mapbefine.mapbefine.common.annotation.ServiceTest; -import com.mapbefine.mapbefine.image.FileFixture; import com.mapbefine.mapbefine.location.LocationFixture; import com.mapbefine.mapbefine.location.domain.Location; import com.mapbefine.mapbefine.location.domain.LocationRepository; @@ -41,25 +39,24 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; @ServiceTest class TopicCommandServiceTest extends TestDatabaseContainer { @Autowired - private MemberRepository memberRepository; + private TestEntityManager testEntityManager; + @Autowired + private MemberRepository memberRepository; @Autowired private LocationRepository locationRepository; - @Autowired private TopicRepository topicRepository; - @Autowired private PinRepository pinRepository; - @Autowired private TopicCommandService topicCommandService; - @Autowired private TopicQueryService topicQueryService; @@ -137,7 +134,7 @@ void saveEmptyTopic_Fail() { } @Test - @DisplayName("핀을 통해 새로운 토픽을 생성할 수 있다.") + @DisplayName("기존 핀을 뽑아온 새로운 토픽을 생성할 수 있다.") void saveTopicWithPins_Success() { //given Topic topic = TopicFixture.createPublicAndAllMembersTopic(member); @@ -154,6 +151,7 @@ void saveTopicWithPins_Success() { ); Long topicId = topicCommandService.saveTopic(user, request); + testEntityManager.clear(); //then TopicDetailResponse detail = topicQueryService.findDetailById(user, topicId); @@ -237,6 +235,7 @@ void merge_Success() { ); Long topicId = topicCommandService.merge(user, request); + testEntityManager.clear(); //then TopicDetailResponse detail = topicQueryService.findDetailById(user, topicId); @@ -316,7 +315,7 @@ void copyPin_Success() { topicCommandService.copyPin(user, target.getId(), pinIds); // then - List targetPins = target.getPins(); + List targetPins = pinRepository.findAllByTopicId(target.getId()); Pin targetPin = targetPins.iterator().next(); Pin sourcePin = sourcePins.get(0); diff --git a/backend/src/test/java/com/mapbefine/mapbefine/topic/application/TopicQueryServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/topic/application/TopicQueryServiceTest.java index 98790d4b..b9f61b04 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/topic/application/TopicQueryServiceTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/topic/application/TopicQueryServiceTest.java @@ -1,7 +1,10 @@ package com.mapbefine.mapbefine.topic.application; +import static com.mapbefine.mapbefine.member.MemberFixture.createUser; +import static java.util.List.of; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; import com.mapbefine.mapbefine.TestDatabaseContainer; import com.mapbefine.mapbefine.auth.domain.AuthMember; @@ -22,8 +25,10 @@ import com.mapbefine.mapbefine.pin.domain.Pin; import com.mapbefine.mapbefine.pin.domain.PinRepository; import com.mapbefine.mapbefine.topic.TopicFixture; +import com.mapbefine.mapbefine.topic.domain.Cluster; import com.mapbefine.mapbefine.topic.domain.Topic; import com.mapbefine.mapbefine.topic.domain.TopicRepository; +import com.mapbefine.mapbefine.topic.dto.response.ClusterResponse; import com.mapbefine.mapbefine.topic.dto.response.TopicDetailResponse; import com.mapbefine.mapbefine.topic.dto.response.TopicResponse; import com.mapbefine.mapbefine.topic.exception.TopicException.TopicForbiddenException; @@ -88,7 +93,7 @@ void findAllReadable_User_Success() { saveAllReadableTopicOfCount(1); saveOnlyMemberReadableTopicOfCount(2); - AuthMember user = MemberFixture.createUser(member); + AuthMember user = createUser(member); //when List topics = topicQueryService.findAllReadable(user); @@ -174,7 +179,7 @@ void findAllReadableWithBookmark_Success() { bookmarkRepository.save(bookmark); //when then - AuthMember user = MemberFixture.createUser(member); + AuthMember user = createUser(member); List topics = topicQueryService.findAllReadable(user); assertThat(topics).hasSize(2); @@ -288,7 +293,7 @@ void findDetailById_WithOutSoftDeleted_Success() { topicRepository.deleteById(topic.getId()); //when then - AuthMember user = MemberFixture.createUser(member); + AuthMember user = createUser(member); assertThatThrownBy(() -> topicQueryService.findDetailById(user, topic.getId())) .isInstanceOf(TopicNotFoundException.class); } @@ -304,7 +309,7 @@ void findDetailById_WithBookmarkStatus_Success() { bookmarkRepository.save(bookmark); //when then - AuthMember user = MemberFixture.createUser(member); + AuthMember user = createUser(member); TopicDetailResponse topicDetail = topicQueryService.findDetailById(user, topic.getId()); assertThat(topicDetail.id()).isEqualTo(topic.getId()); @@ -348,7 +353,7 @@ void findDetailsByIds_Guest_Success() { List details = topicQueryService.findDetailsByIds( guest, - List.of(topic1.getId(), topic2.getId()) + of(topic1.getId(), topic2.getId()) ); //then @@ -368,11 +373,11 @@ void findDetailsByIds_User_Success() { topicRepository.save(topic2); //when - AuthMember user = MemberFixture.createUser(member); + AuthMember user = createUser(member); List details = topicQueryService.findDetailsByIds( user, - List.of(topic1.getId(), topic2.getId()) + of(topic1.getId(), topic2.getId()) ); //then @@ -393,7 +398,7 @@ void findDetailsByIds_FailByForbidden() { //when then AuthMember guest = new Guest(); - List topicIds = List.of(topic1.getId(), topic2.getId()); + List topicIds = of(topic1.getId(), topic2.getId()); assertThatThrownBy(() -> topicQueryService.findDetailsByIds(guest, topicIds)) .isInstanceOf(TopicForbiddenException.class); @@ -411,8 +416,8 @@ void findDetailsByIds_FailByNotFound() { topicRepository.deleteById(topic2.getId()); //when then - AuthMember user = MemberFixture.createUser(member); - List topicIds = List.of(topic1.getId(), topic2.getId()); + AuthMember user = createUser(member); + List topicIds = of(topic1.getId(), topic2.getId()); assertThatThrownBy(() -> topicQueryService.findDetailsByIds(user, topicIds)) .isInstanceOf(TopicNotFoundException.class); @@ -431,9 +436,9 @@ void findDetailsByIds_WithBookmarkStatus_Success() { bookmarkRepository.save(bookmark); //when //then - AuthMember user = MemberFixture.createUser(member); + AuthMember user = createUser(member); List topicDetails = - topicQueryService.findDetailsByIds(user, List.of(topic1.getId(), topic2.getId())); + topicQueryService.findDetailsByIds(user, of(topic1.getId(), topic2.getId())); assertThat(topicDetails).hasSize(2); assertThat(topicDetails).extractingResultOf("id") @@ -457,7 +462,7 @@ void findDetailsByIds_WithoutBookmarkStatus_Success() { //when //then AuthMember guest = new Guest(); List topicDetails = - topicQueryService.findDetailsByIds(guest, List.of(topic1.getId(), topic2.getId())); + topicQueryService.findDetailsByIds(guest, of(topic1.getId(), topic2.getId())); assertThat(topicDetails).hasSize(2); assertThat(topicDetails).extractingResultOf("id") @@ -472,7 +477,7 @@ void findAllTopicsByMemberId_Success() { //given AuthMember authMember = new Admin(member.getId()); - List expected = topicRepository.saveAll(List.of( + List expected = topicRepository.saveAll(of( TopicFixture.createPublicAndAllMembersTopic(member), TopicFixture.createPublicAndAllMembersTopic(member), TopicFixture.createPublicAndAllMembersTopic(member) @@ -497,7 +502,7 @@ void findAllByOrderByUpdatedAtDesc_Success() { Location location = LocationFixture.create(); locationRepository.save(location); - List topics = List.of( + List topics = of( TopicFixture.createByName("5등", member), TopicFixture.createByName("4등", member), TopicFixture.createByName("3등", member), @@ -544,7 +549,7 @@ void findAllBestTopics_Success1() { saveBookmark(topicWithTwoBookmark, otherMember); //when - AuthMember user = MemberFixture.createUser(member); + AuthMember user = createUser(member); List actual = topicQueryService.findAllBestTopics(user); //then @@ -575,7 +580,7 @@ void findAllBestTopics_Success2() { saveBookmark(privateTopicWithOneBookmark, member); //when - AuthMember otherUser = MemberFixture.createUser(otherMember); + AuthMember otherUser = createUser(otherMember); List actual = topicQueryService.findAllBestTopics(otherUser); List expect = topicQueryService.findAllReadable(otherUser); @@ -585,6 +590,65 @@ void findAllBestTopics_Success2() { .isEqualTo(expect); } + @Test + @DisplayName("여러 지도를 한번에 렌더링 할 때, 클러스터링 된 결과를 반환받는다.") + void findClusteringPinsByIds_success() { + // given + Topic firstTopic = topicRepository.save(TopicFixture.createByName("firstTopic", member)); + Topic secondTopic = topicRepository.save(TopicFixture.createByName("secondTopic", member)); + Pin representPinOfSet1 = pinRepository.save(PinFixture.create( + locationRepository.save(LocationFixture.createByCoordinate(36, 124)), firstTopic, member) + ); + Pin pinOfSet1 = pinRepository.save(PinFixture.create( + locationRepository.save(LocationFixture.createByCoordinate(36, 124.1)), secondTopic, member) + ); + Pin representPinOfSet2 = pinRepository.save(PinFixture.create( + locationRepository.save(LocationFixture.createByCoordinate(36, 124.2)), firstTopic, member) + ); + Pin representPinOfSet3 = pinRepository.save(PinFixture.create( + locationRepository.save(LocationFixture.createByCoordinate(38, 124)), firstTopic, member) + ); + Pin pinOfSet3 = pinRepository.save(PinFixture.create( + locationRepository.save(LocationFixture.createByCoordinate(38, 124.1)), secondTopic, member) + ); + + // when + List actual = topicQueryService.findClustersPinsByIds( + createUser(member), + List.of(firstTopic.getId(), secondTopic.getId()), + 9000D + ); + + // then + List expected = List.of( + ClusterResponse.from(Cluster.from(representPinOfSet1, List.of(representPinOfSet1, pinOfSet1))), + ClusterResponse.from(Cluster.from(representPinOfSet2, List.of(representPinOfSet2))), + ClusterResponse.from(Cluster.from(representPinOfSet3, List.of(representPinOfSet3, pinOfSet3))) + ); + assertAll( + () -> assertThat(actual).hasSize(3), + () -> assertThat(actual).usingRecursiveComparison() + .ignoringCollectionOrder() + .isEqualTo(expected) + ); + } + + @Test + @DisplayName("여러 지도를 한번에 렌더링 할 떄, 조회하지 못하는 지도가 있어 예외가 발생한다.") + void findClusteringPinsByIds_fail() { + // given + Member nonCreator = memberRepository.save(MemberFixture.create("nonCreator", "nonCreator@naver.com", Role.USER)); + Member creator = memberRepository.save(MemberFixture.create("creator", "creator@naver.com", Role.USER)); + Topic topic = topicRepository.save(TopicFixture.createPrivateAndGroupOnlyTopic(creator)); + + // when then + assertThatThrownBy(() -> topicQueryService.findClustersPinsByIds( + createUser(nonCreator), + List.of(topic.getId()), + 9000D + )).isInstanceOf(TopicForbiddenException.class); + } + private Bookmark saveBookmark(Topic topic, Member member) { return bookmarkRepository.save(Bookmark.createWithAssociatedTopicAndMember(topic, member)); } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/ClusterTest.java b/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/ClusterTest.java new file mode 100644 index 00000000..ef2200ed --- /dev/null +++ b/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/ClusterTest.java @@ -0,0 +1,46 @@ +package com.mapbefine.mapbefine.topic.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.mapbefine.mapbefine.location.LocationFixture; +import com.mapbefine.mapbefine.location.domain.Location; +import com.mapbefine.mapbefine.member.MemberFixture; +import com.mapbefine.mapbefine.member.domain.Member; +import com.mapbefine.mapbefine.member.domain.Role; +import com.mapbefine.mapbefine.pin.PinFixture; +import com.mapbefine.mapbefine.pin.domain.Pin; +import com.mapbefine.mapbefine.topic.TopicFixture; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ClusterTest { + + @Test + @DisplayName("대표 핀과, 해당 좌표에 묶인 핀들의 집합을 받으면 정상적으로 생성된다.") + void createCluster() { + // given + Member member = MemberFixture.create("member", "member@naver.com", Role.USER); + Topic topic = TopicFixture.createByName("topic", member); + Location firstLocation = LocationFixture.createByCoordinate(36, 124); + Location secondLocation = LocationFixture.createByCoordinate(36, 124.1); + List pins = List.of( + PinFixture.create(firstLocation, topic, member), + PinFixture.create(secondLocation, topic, member) + ); + + // when + Cluster actual = Cluster.from(pins.get(0), pins); + + // then + assertAll( + () -> assertThat(actual.getLatitude()).isEqualTo(36), + () -> assertThat(actual.getLongitude()).isEqualTo(124), + () -> assertThat(actual.getPins().get(0)).isEqualTo(pins.get(0)), + () -> assertThat(actual.getPins()).usingRecursiveComparison() + .isEqualTo(pins) + ); + } + +} diff --git a/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/ClustersTest.java b/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/ClustersTest.java new file mode 100644 index 00000000..07aff7b4 --- /dev/null +++ b/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/ClustersTest.java @@ -0,0 +1,64 @@ +package com.mapbefine.mapbefine.topic.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.mapbefine.mapbefine.location.LocationFixture; +import com.mapbefine.mapbefine.member.MemberFixture; +import com.mapbefine.mapbefine.member.domain.Member; +import com.mapbefine.mapbefine.member.domain.Role; +import com.mapbefine.mapbefine.pin.PinFixture; +import com.mapbefine.mapbefine.pin.domain.Pin; +import com.mapbefine.mapbefine.topic.TopicFixture; +import com.mapbefine.mapbefine.topic.exception.TopicException.TopicBadRequestException; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ClustersTest { + + @Test + @DisplayName("각기 약 9000미터 떨어져있는 핀 5개를 받아 3개의 집합이 완성된다.") + void createClusters_success() { + // given + Member member = MemberFixture.create("member", "member@naver.com", Role.USER); + Topic topic = TopicFixture.createByName("topic", member); + List pins = List.of( + PinFixture.create(LocationFixture.createByCoordinate(36, 124), topic, member), + PinFixture.create(LocationFixture.createByCoordinate(36, 124.1), topic, member), + PinFixture.create(LocationFixture.createByCoordinate(36, 124.2), topic, member), + PinFixture.create(LocationFixture.createByCoordinate(38, 124), topic, member), + PinFixture.create(LocationFixture.createByCoordinate(38, 124.1), topic, member) + ); + + // when + List actual = Clusters.from(pins, 9000D) + .getClusters(); + + // then + assertAll( + () -> assertThat(actual).hasSize(3), + () -> assertThat(actual) + .extracting(Cluster::getLatitude, Cluster::getLongitude) + .contains(tuple(36d, 124d), tuple(36d, 124.2d), tuple(38d, 124d)) + ); + } + + @Test + @DisplayName("이미지의 크기를 입력하지 않으면 예외가 발생한다.") + void createClusters_fail() { + // given + Member member = MemberFixture.create("member", "member@naver.com", Role.USER); + Topic topic = TopicFixture.createByName("topic", member); + List pins = List.of( + PinFixture.create(LocationFixture.createByCoordinate(36, 124), topic, member) + ); + + // when then + assertThatThrownBy(() -> Clusters.from(pins, null)) + .isInstanceOf(TopicBadRequestException.class); + } + +} diff --git a/backend/src/test/java/com/mapbefine/mapbefine/topic/presentation/TopicControllerTest.java b/backend/src/test/java/com/mapbefine/mapbefine/topic/presentation/TopicControllerTest.java index e1c94953..bdc553f7 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/topic/presentation/TopicControllerTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/topic/presentation/TopicControllerTest.java @@ -2,6 +2,7 @@ import static org.apache.http.HttpHeaders.AUTHORIZATION; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.BDDMockito.given; import static org.springframework.http.HttpMethod.POST; import static org.springframework.http.HttpMethod.PUT; @@ -18,6 +19,8 @@ import com.mapbefine.mapbefine.topic.dto.request.TopicCreateRequestWithoutImage; import com.mapbefine.mapbefine.topic.dto.request.TopicMergeRequestWithoutImage; import com.mapbefine.mapbefine.topic.dto.request.TopicUpdateRequest; +import com.mapbefine.mapbefine.topic.dto.response.ClusterResponse; +import com.mapbefine.mapbefine.topic.dto.response.RenderPinResponse; import com.mapbefine.mapbefine.topic.dto.response.TopicDetailResponse; import com.mapbefine.mapbefine.topic.dto.response.TopicResponse; import java.time.LocalDateTime; @@ -243,4 +246,41 @@ void findAllBestTopics() throws Exception { .andDo(restDocs.document()); } + @Test + @DisplayName("클러스터링 된 핀들을 조회할 수 있다.") + void getClustersOfPins() throws Exception { + given(topicQueryService.findClustersPinsByIds(any(), any(), anyDouble())).willReturn( + List.of( + new ClusterResponse( + 36.0, + 124.0, + List.of( + new RenderPinResponse(1L, "firstTopic의 핀", 1L), + new RenderPinResponse(2L, "secondTopic의 핀", 2L) + ) + ), + new ClusterResponse( + 36.0, + 124.2, + List.of( + new RenderPinResponse(3L, "firstTopic의 핀", 1L) + ) + ), + new ClusterResponse( + 38.0, + 124.0, + List.of( + new RenderPinResponse(4L, "firstTopic의 핀", 1L), + new RenderPinResponse(5L, "secondTopic의 핀", 2L) + ) + ) + ) + ); + + mockMvc.perform(MockMvcRequestBuilders.get("/topics/clusters?ids=1,2&image-diameter=9000") + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeaderById(1L))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andDo(restDocs.document()); + } + } diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index d909422c..9605c722 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -19,6 +19,27 @@ module.exports = { ], 'simple-import-sort/imports': 'error', 'simple-import-sort/exports': 'error', + 'no-undef': 'off', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': 'off', + 'import/prefer-default-export': 'off', + 'no-use-before-define': 'off', + 'react/require-default-props': 'off', + 'react/destructuring-assignment': 'off', + 'react/jsx-no-constructed-context-values': 'off', + 'no-restricted-globals': 'off', + 'no-shadow': 'off', + 'consistent-return': 'off', + 'no-restricted-syntax': 'off', + 'no-await-in-loop': 'off', + 'no-param-reassign': 'off', + 'jsx-a11y/tabindex-no-positive': 'off', + 'react/no-array-index-key': 'off', + 'react/jsx-no-useless-fragment': 'off', + 'no-unused-expressions': 'off', + 'react/jsx-props-no-spreading': 'off', + 'react/no-unused-prop-types': 'off', + 'import/no-extraneous-dependencies': 'off', }, settings: { 'import/resolver': { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a1931269..e3a437bb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,12 +10,14 @@ "license": "ISC", "dependencies": { "history": "^5.3.0", + "map-befine-swiper": "^0.8.3", "msw-storybook-addon": "^1.8.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-ga4": "^2.1.0", "react-router-dom": "^6.14.1", - "styled-components": "^6.0.3" + "styled-components": "^6.0.3", + "zustand": "^4.4.3" }, "devDependencies": { "@babel/core": "^7.22.8", @@ -41,6 +43,7 @@ "@types/react-dom": "^18.2.6", "@types/react-router-dom": "^5.3.3", "@types/styled-components": "^5.1.26", + "@typescript-eslint/parser": "^6.7.4", "babel-loader": "^9.1.3", "babel-plugin-styled-components": "^2.1.4", "browser-image-compression": "^2.0.2", @@ -7639,7 +7642,7 @@ "version": "15.7.7", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.7.tgz", "integrity": "sha512-FbtmBWCcSa2J4zL781Zf1p5YUBXQomPEcep9QZCfRfQgTxz3pJWiDFLebohZ9fFntX5ibzOkSsrJ0TEew8cAog==", - "dev": true + "devOptional": true }, "node_modules/@types/qs": { "version": "6.9.8", @@ -7657,7 +7660,7 @@ "version": "18.2.23", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.23.tgz", "integrity": "sha512-qHLW6n1q2+7KyBEYnrZpcsAmU/iiCh9WGCKgXvMxx89+TYdJWRjZohVIo9XTcoLhfX3+/hP0Pbulu3bCZQ9PSA==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -7704,7 +7707,7 @@ "version": "0.16.4", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.4.tgz", "integrity": "sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ==", - "dev": true + "devOptional": true }, "node_modules/@types/semver": { "version": "7.5.3", @@ -7848,6 +7851,141 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/parser": { + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.7.4.tgz", + "integrity": "sha512-I5zVZFY+cw4IMZUeNCU7Sh2PO5O57F7Lr0uyhgCJmhN/BuTlnc55KxPonR4+EM3GBdfiCyGZye6DgMjtubQkmA==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "6.7.4", + "@typescript-eslint/types": "6.7.4", + "@typescript-eslint/typescript-estree": "6.7.4", + "@typescript-eslint/visitor-keys": "6.7.4", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.7.4.tgz", + "integrity": "sha512-SdGqSLUPTXAXi7c3Ob7peAGVnmMoGzZ361VswK2Mqf8UOYcODiYvs8rs5ILqEdfvX1lE7wEZbLyELCW+Yrql1A==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.7.4", + "@typescript-eslint/visitor-keys": "6.7.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.7.4.tgz", + "integrity": "sha512-o9XWK2FLW6eSS/0r/tgjAGsYasLAnOWg7hvZ/dGYSSNjCh+49k5ocPN8OmG5aZcSJ8pclSOyVKP2x03Sj+RrCA==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.7.4.tgz", + "integrity": "sha512-ty8b5qHKatlNYd9vmpHooQz3Vki3gG+3PchmtsA4TgrZBKWHNjWfkQid7K7xQogBqqc7/BhGazxMD5vr6Ha+iQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.7.4", + "@typescript-eslint/visitor-keys": "6.7.4", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.7.4.tgz", + "integrity": "sha512-pOW37DUhlTZbvph50x5zZCkFn3xzwkGtNoJHzIM3svpiSkJzwOYr/kVBaXmf+RAQiUDs1AHEZVNPg6UJCJpwRA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.7.4", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "node_modules/@typescript-eslint/scope-manager": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", @@ -18169,6 +18307,16 @@ "tmpl": "1.0.5" } }, + "node_modules/map-befine-swiper": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/map-befine-swiper/-/map-befine-swiper-0.8.3.tgz", + "integrity": "sha512-yWUTckvw6hX/Qu+KwLV26UFKVfZyjje8EbMh6aN16DS4KNttO2vTQXUSTWbI8YarOpGyax1rwK0ImNTIaITdgw==", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "styled-components": "^6.0.8" + } + }, "node_modules/map-obj": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", @@ -22854,6 +23002,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ts-api-utils": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", + "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==", + "dev": true, + "engines": { + "node": ">=16.13.0" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/ts-dedent": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", @@ -23440,6 +23600,14 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -24447,6 +24615,33 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.4.3.tgz", + "integrity": "sha512-oRy+X3ZazZvLfmv6viIaQmtLOMeij1noakIsK/Y47PWYhT8glfXzQ4j0YcP5i0P0qI1A4rIB//SGROGyZhx91A==", + "dependencies": { + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index 78d05195..1c883032 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,12 +17,14 @@ "license": "ISC", "dependencies": { "history": "^5.3.0", + "map-befine-swiper": "^0.8.3", "msw-storybook-addon": "^1.8.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-ga4": "^2.1.0", "react-router-dom": "^6.14.1", - "styled-components": "^6.0.3" + "styled-components": "^6.0.3", + "zustand": "^4.4.3" }, "devDependencies": { "@babel/core": "^7.22.8", @@ -48,6 +50,7 @@ "@types/react-dom": "^18.2.6", "@types/react-router-dom": "^5.3.3", "@types/styled-components": "^5.1.26", + "@typescript-eslint/parser": "^6.7.4", "babel-loader": "^9.1.3", "babel-plugin-styled-components": "^2.1.4", "browser-image-compression": "^2.0.2", diff --git a/frontend/public/index.html b/frontend/public/index.html index dbc158a4..8a529c1d 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -4,7 +4,7 @@ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 719a571a..8eeccf6f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,9 @@ import { RouterProvider } from 'react-router-dom'; + import router from './router'; -const App = () => { +function App() { return ; -}; +} export default App; diff --git a/frontend/src/GlobalStyle.ts b/frontend/src/GlobalStyle.ts index 4ea4a933..e8176c04 100644 --- a/frontend/src/GlobalStyle.ts +++ b/frontend/src/GlobalStyle.ts @@ -9,19 +9,19 @@ const GlobalStyle = createGlobalStyle` font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; } - a{ + a { text-decoration:none; color: #000; transition: all 0.3s ease-in-out; } - ul{ + ul { padding-left: 0; margin:0; list-style-type: none; } - *{ + * { box-sizing:border-box; } diff --git a/frontend/src/apiHooks/usePatch.ts b/frontend/src/apiHooks/usePatch.ts index e5a0e227..ff2476f3 100644 --- a/frontend/src/apiHooks/usePatch.ts +++ b/frontend/src/apiHooks/usePatch.ts @@ -35,6 +35,8 @@ const usePatch = () => { if (isThrow) throw e; } + + return null; }; return { fetchPatch }; diff --git a/frontend/src/apiHooks/usePost.ts b/frontend/src/apiHooks/usePost.ts index 70cbda53..72df6762 100644 --- a/frontend/src/apiHooks/usePost.ts +++ b/frontend/src/apiHooks/usePost.ts @@ -35,6 +35,8 @@ const usePost = () => { if (isThrow) throw e; } + + return null; }; return { fetchPost }; diff --git a/frontend/src/apiHooks/usePut.ts b/frontend/src/apiHooks/usePut.ts index a263afdb..78f7486b 100644 --- a/frontend/src/apiHooks/usePut.ts +++ b/frontend/src/apiHooks/usePut.ts @@ -35,6 +35,8 @@ const usePut = () => { if (isThrow) throw e; } + + return null; }; return { fetchPut }; diff --git a/frontend/src/apis/deleteApi.ts b/frontend/src/apis/deleteApi.ts index 3ce0f215..2ae319b1 100644 --- a/frontend/src/apis/deleteApi.ts +++ b/frontend/src/apis/deleteApi.ts @@ -8,7 +8,7 @@ interface Headers { } export const deleteApi = async (url: string, contentType?: ContentTypeType) => { - return await withTokenRefresh(async () => { + const data = await withTokenRefresh(async () => { const apiUrl = `${DEFAULT_PROD_URL + url}`; const userToken = localStorage.getItem('userToken'); const headers: Headers = { @@ -16,7 +16,7 @@ export const deleteApi = async (url: string, contentType?: ContentTypeType) => { }; if (userToken) { - headers['Authorization'] = `Bearer ${userToken}`; + headers.Authorization = `Bearer ${userToken}`; } if (contentType) { @@ -32,4 +32,6 @@ export const deleteApi = async (url: string, contentType?: ContentTypeType) => { throw new Error('[SERVER] DELETE 요청에 실패했습니다.'); } }); + + return data; }; diff --git a/frontend/src/apis/getApi.ts b/frontend/src/apis/getApi.ts index f4c99b91..fd0cb7bf 100644 --- a/frontend/src/apis/getApi.ts +++ b/frontend/src/apis/getApi.ts @@ -2,7 +2,7 @@ import { DEFAULT_PROD_URL } from '../constants'; import withTokenRefresh from './utils'; export const getApi = async (url: string) => { - return await withTokenRefresh(async () => { + const data = await withTokenRefresh(async () => { const apiUrl = `${DEFAULT_PROD_URL + url}`; const userToken = localStorage.getItem('userToken'); const headers: any = { @@ -10,7 +10,7 @@ export const getApi = async (url: string) => { }; if (userToken) { - headers['Authorization'] = `Bearer ${userToken}`; + headers.Authorization = `Bearer ${userToken}`; } const response = await fetch(apiUrl, { method: 'GET', headers }); @@ -22,4 +22,6 @@ export const getApi = async (url: string) => { const responseData: T = await response.json(); return responseData; }); + + return data; }; diff --git a/frontend/src/apis/getPoiApi.ts b/frontend/src/apis/getPoiApi.ts new file mode 100644 index 00000000..013c8491 --- /dev/null +++ b/frontend/src/apis/getPoiApi.ts @@ -0,0 +1,19 @@ +import { PoiApiResponse } from '../types/Poi'; + +export const getPoiApi = async (query: string): Promise => { + const response = await fetch( + `https://apis.openapi.sk.com/tmap/pois?version=1&format=json&callback=result&searchKeyword=${query}&resCoordType=WGS84GEO&reqCoordType=WGS84GEO&count=10`, + { + method: 'GET', + headers: { appKey: process.env.REACT_APP_TMAP_API_KEY || '' }, + }, + ); + + if (response.status >= 400) { + throw new Error('[POI] GET 요청에 실패했습니다.'); + } + + const responseData = await response.json(); + + return responseData; +}; diff --git a/frontend/src/apis/patchApi.ts b/frontend/src/apis/patchApi.ts index 358bce0d..d2b049a3 100644 --- a/frontend/src/apis/patchApi.ts +++ b/frontend/src/apis/patchApi.ts @@ -4,10 +4,10 @@ import withTokenRefresh from './utils'; export const patchApi = async ( url: string, - data: {}, + payload: {}, contentType?: ContentTypeType, ) => { - return await withTokenRefresh(async () => { + const data = await withTokenRefresh(async () => { const apiUrl = `${DEFAULT_PROD_URL + url}`; const userToken = localStorage.getItem('userToken'); const headers: any = { @@ -15,7 +15,7 @@ export const patchApi = async ( }; if (userToken) { - headers['Authorization'] = `Bearer ${userToken}`; + headers.Authorization = `Bearer ${userToken}`; } if (contentType) { @@ -25,7 +25,7 @@ export const patchApi = async ( const response = await fetch(apiUrl, { method: 'PATCH', headers, - body: JSON.stringify(data), + body: JSON.stringify(payload), }); if (response.status >= 400) { @@ -34,4 +34,6 @@ export const patchApi = async ( return response; }); + + return data; }; diff --git a/frontend/src/apis/postApi.ts b/frontend/src/apis/postApi.ts index 8df139ce..63c2d65a 100644 --- a/frontend/src/apis/postApi.ts +++ b/frontend/src/apis/postApi.ts @@ -7,7 +7,7 @@ export const postApi = async ( payload?: {} | FormData, contentType?: ContentTypeType, ) => { - return await withTokenRefresh(async () => { + const data = await withTokenRefresh(async () => { const apiUrl = `${DEFAULT_PROD_URL + url}`; const userToken = localStorage.getItem('userToken'); @@ -15,7 +15,7 @@ export const postApi = async ( const headers: any = {}; if (userToken) { - headers['Authorization'] = `Bearer ${userToken}`; + headers.Authorization = `Bearer ${userToken}`; } const response = await fetch(apiUrl, { @@ -36,7 +36,7 @@ export const postApi = async ( }; if (userToken) { - headers['Authorization'] = `Bearer ${userToken}`; + headers.Authorization = `Bearer ${userToken}`; } if (contentType) { @@ -55,4 +55,6 @@ export const postApi = async ( return response; }); + + return data; }; diff --git a/frontend/src/apis/putApi.ts b/frontend/src/apis/putApi.ts index 6335503c..e84978e7 100644 --- a/frontend/src/apis/putApi.ts +++ b/frontend/src/apis/putApi.ts @@ -4,18 +4,39 @@ import withTokenRefresh from './utils'; export const putApi = async ( url: string, - data: {}, + payload: {} | FormData, contentType?: ContentTypeType, ) => { - return await withTokenRefresh(async () => { + const data = await withTokenRefresh(async () => { const apiUrl = `${DEFAULT_PROD_URL + url}`; const userToken = localStorage.getItem('userToken'); + + if (payload instanceof FormData) { + const headers: any = {}; + + if (userToken) { + headers.Authorization = `Bearer ${userToken}`; + } + + const response = await fetch(apiUrl, { + method: 'PUT', + headers, + body: payload, + }); + + if (response.status >= 400) { + throw new Error('[SERVER] POST 요청에 실패했습니다.'); + } + + return response; + } + const headers: any = { 'content-type': 'application/json', }; if (userToken) { - headers['Authorization'] = `Bearer ${userToken}`; + headers.Authorization = `Bearer ${userToken}`; } if (contentType) { @@ -25,7 +46,7 @@ export const putApi = async ( const response = await fetch(apiUrl, { method: 'PUT', headers, - body: JSON.stringify(data), + body: JSON.stringify(payload), }); if (response.status >= 400) { @@ -34,4 +55,6 @@ export const putApi = async ( return response; }); + + return data; }; diff --git a/frontend/src/apis/utils.ts b/frontend/src/apis/utils.ts index c5b69af3..28d0c5e1 100644 --- a/frontend/src/apis/utils.ts +++ b/frontend/src/apis/utils.ts @@ -25,7 +25,7 @@ async function refreshToken(headers: Headers): Promise { method: 'POST', headers, body: JSON.stringify({ - accessToken: accessToken, + accessToken, }), }); @@ -61,8 +61,6 @@ async function updateToken(headers: Headers) { localStorage.setItem('userToken', newToken.accessToken); } catch (e) { console.error(e); - - return; } } diff --git a/frontend/src/assets/banner_boong.webp b/frontend/src/assets/banner_boong.webp new file mode 100644 index 00000000..7c21bd12 Binary files /dev/null and b/frontend/src/assets/banner_boong.webp differ diff --git a/frontend/src/assets/banner_usage.webp b/frontend/src/assets/banner_usage.webp new file mode 100644 index 00000000..0046af00 Binary files /dev/null and b/frontend/src/assets/banner_usage.webp differ diff --git a/frontend/src/assets/currentLocationBtn.svg b/frontend/src/assets/currentLocationBtn.svg new file mode 100644 index 00000000..a75acdc9 --- /dev/null +++ b/frontend/src/assets/currentLocationBtn.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/favoriteBtn_notFilled.svg b/frontend/src/assets/favoriteBtn_notFilled.svg index c071b399..40cddaac 100644 --- a/frontend/src/assets/favoriteBtn_notFilled.svg +++ b/frontend/src/assets/favoriteBtn_notFilled.svg @@ -1,4 +1,4 @@ - - + + diff --git a/frontend/src/assets/profile_defaultImage.svg b/frontend/src/assets/profile_defaultImage.svg deleted file mode 100644 index edafb60a..00000000 --- a/frontend/src/assets/profile_defaultImage.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/frontend/src/assets/remove_image_icon.svg b/frontend/src/assets/remove_image_icon.svg new file mode 100644 index 00000000..f4f48825 --- /dev/null +++ b/frontend/src/assets/remove_image_icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/search.svg b/frontend/src/assets/search.svg new file mode 100644 index 00000000..1012218e --- /dev/null +++ b/frontend/src/assets/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/seeTogetherBtn_filled.svg b/frontend/src/assets/seeTogetherBtn_filled.svg index 4f93eab3..7c3b1e12 100644 --- a/frontend/src/assets/seeTogetherBtn_filled.svg +++ b/frontend/src/assets/seeTogetherBtn_filled.svg @@ -1,4 +1,4 @@ - + diff --git a/frontend/src/assets/seeTogetherBtn_notFilled.svg b/frontend/src/assets/seeTogetherBtn_notFilled.svg index 47a831a2..edf97584 100644 --- a/frontend/src/assets/seeTogetherBtn_notFilled.svg +++ b/frontend/src/assets/seeTogetherBtn_notFilled.svg @@ -1,5 +1,4 @@ - - - + + diff --git a/frontend/src/assets/setting.svg b/frontend/src/assets/setting.svg deleted file mode 100644 index b24c6567..00000000 --- a/frontend/src/assets/setting.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/src/assets/swiper_left_button.svg b/frontend/src/assets/swiper_left_button.svg new file mode 100644 index 00000000..231d0170 --- /dev/null +++ b/frontend/src/assets/swiper_left_button.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/swiper_right_button.svg b/frontend/src/assets/swiper_right_button.svg new file mode 100644 index 00000000..ceebcf7c --- /dev/null +++ b/frontend/src/assets/swiper_right_button.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/topicInfo_seeTogetherBtn_notFilled.svg b/frontend/src/assets/topicInfo_seeTogetherBtn_notFilled.svg index 91d8bbba..1a00db01 100644 --- a/frontend/src/assets/topicInfo_seeTogetherBtn_notFilled.svg +++ b/frontend/src/assets/topicInfo_seeTogetherBtn_notFilled.svg @@ -1,4 +1,4 @@ - + diff --git a/frontend/src/assets/topicInfo_shareUrl.svg b/frontend/src/assets/topicInfo_shareUrl.svg index 96eabcaa..d68596f7 100644 --- a/frontend/src/assets/topicInfo_shareUrl.svg +++ b/frontend/src/assets/topicInfo_shareUrl.svg @@ -1,5 +1,6 @@ - - + + + diff --git a/frontend/src/assets/updateBtn.svg b/frontend/src/assets/updateBtn.svg new file mode 100644 index 00000000..7331c808 --- /dev/null +++ b/frontend/src/assets/updateBtn.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/AddFavorite/index.tsx b/frontend/src/components/AddFavorite/index.tsx index fe46e1c3..7acc3c34 100644 --- a/frontend/src/components/AddFavorite/index.tsx +++ b/frontend/src/components/AddFavorite/index.tsx @@ -1,7 +1,8 @@ import { styled } from 'styled-components'; + +import { deleteApi } from '../../apis/deleteApi'; import { postApi } from '../../apis/postApi'; import useToast from '../../hooks/useToast'; -import { deleteApi } from '../../apis/deleteApi'; interface AddFavoriteProps { id: number; @@ -10,12 +11,12 @@ interface AddFavoriteProps { children: React.ReactNode; } -const AddFavorite = ({ +function AddFavorite({ id, isBookmarked, getTopicsFromServer, children, -}: AddFavoriteProps) => { +}: AddFavoriteProps) { const { showToast } = useToast(); const addFavoriteList = async (e: React.MouseEvent) => { @@ -51,7 +52,7 @@ const AddFavorite = ({ {children} ); -}; +} const Wrapper = styled.div` cursor: pointer; diff --git a/frontend/src/components/AddSeeTogether/index.tsx b/frontend/src/components/AddSeeTogether/index.tsx index 9acfed43..9d2604b9 100644 --- a/frontend/src/components/AddSeeTogether/index.tsx +++ b/frontend/src/components/AddSeeTogether/index.tsx @@ -1,35 +1,44 @@ +import { useContext } from 'react'; import { styled } from 'styled-components'; + +import { deleteApi } from '../../apis/deleteApi'; +import { getApi } from '../../apis/getApi'; import { postApi } from '../../apis/postApi'; +import { SeeTogetherContext } from '../../context/SeeTogetherContext'; +import useNavigator from '../../hooks/useNavigator'; import useToast from '../../hooks/useToast'; -import { useContext } from 'react'; -import { getApi } from '../../apis/getApi'; import { TopicCardProps } from '../../types/Topic'; -import { SeeTogetherContext } from '../../context/SeeTogetherContext'; -import { deleteApi } from '../../apis/deleteApi'; interface AddSeeTogetherProps { + parentType: 'topicCard' | 'topicInfo'; isInAtlas: boolean; id: number; children: React.ReactNode; getTopicsFromServer: () => void; + onClickAtlas: () => boolean; } -const AddSeeTogether = ({ +function AddSeeTogether({ + parentType, isInAtlas, id, children, getTopicsFromServer, -}: AddSeeTogetherProps) => { + onClickAtlas, +}: AddSeeTogetherProps) { const { showToast } = useToast(); const { seeTogetherTopics, setSeeTogetherTopics } = useContext(SeeTogetherContext); + const { routePage } = useNavigator(); + + const accessToken = localStorage.getItem('userToken'); const addSeeTogetherList = async (e: React.MouseEvent) => { e.stopPropagation(); try { if (seeTogetherTopics && seeTogetherTopics.length === 7) { - showToast('warning', '모아보기는 7개까지만 가능합니다.'); + showToast('error', '모아보기는 7개까지만 가능합니다.'); return; } @@ -37,10 +46,11 @@ const AddSeeTogether = ({ const topics = await getApi('/members/my/atlas'); - setSeeTogetherTopics(topics); + setSeeTogetherTopics(topics.map((topic) => topic.id)); // TODO: 모아보기 페이지에서는 GET /members/my/atlas 두 번 됨 getTopicsFromServer(); + onClickAtlas(); showToast('info', '모아보기에 추가했습니다.'); } catch { @@ -56,23 +66,53 @@ const AddSeeTogether = ({ const topics = await getApi('/members/my/atlas'); - setSeeTogetherTopics(topics); + setSeeTogetherTopics(topics.map((topic) => topic.id)); // TODO: 모아보기 페이지에서는 GET /members/my/atlas 두 번 됨 getTopicsFromServer(); + onClickAtlas(); showToast('info', '해당 지도를 모아보기에서 제외했습니다.'); + + if (parentType === 'topicInfo') routePageAfterSuccessToDelete(topics); } catch { showToast('error', '로그인 후 사용해주세요.'); } }; + const routePageAfterSuccessToDelete = (topics: TopicCardProps[]) => { + if (topics.length === 0) { + routePage(`/`); + return; + } + + routePage(`/see-together/${topics.map((topic) => topic.id).join(',')}`); + }; + + const onChangeIsInAtlas = (e: React.MouseEvent) => { + e.stopPropagation(); + + const isChangeAtlas = onClickAtlas(); + + if (isChangeAtlas) { + if (seeTogetherTopics?.includes(id)) + setSeeTogetherTopics( + seeTogetherTopics.filter((topicId) => topicId !== id), + ); + else setSeeTogetherTopics((prev) => [...prev, id]); + } + }; + + if (accessToken === null) { + return {children}; + } + return ( {children} ); -}; +} const Wrapper = styled.div` cursor: pointer; diff --git a/frontend/src/components/AuthorityRadioContainer/index.tsx b/frontend/src/components/AuthorityRadioContainer/index.tsx index 45002ffc..1c5dd0b4 100644 --- a/frontend/src/components/AuthorityRadioContainer/index.tsx +++ b/frontend/src/components/AuthorityRadioContainer/index.tsx @@ -1,20 +1,22 @@ +/* eslint-disable react/require-default-props */ +import { useContext, useState } from 'react'; import styled from 'styled-components'; -import Text from '../common/Text'; -import Space from '../common/Space'; -import Flex from '../common/Flex'; -import { useContext, useEffect, useState } from 'react'; + +import useGet from '../../apiHooks/useGet'; +import { ModalContext } from '../../context/ModalContext'; import { TopicAuthorMember, TopicAuthorMemberWithAuthorId, } from '../../types/Topic'; -import { ModalContext } from '../../context/ModalContext'; import Box from '../common/Box'; -import Modal from '../Modal'; import Button from '../common/Button'; import Checkbox from '../common/CheckBox'; -import useGet from '../../apiHooks/useGet'; +import Flex from '../common/Flex'; +import Space from '../common/Space'; +import Text from '../common/Text'; +import Modal from '../Modal'; -interface AuthorityRadioContainer { +interface AuthorityRadioContainerProps { isPrivate: boolean; isAllPermissioned: boolean; authorizedMemberIds: number[]; @@ -24,7 +26,7 @@ interface AuthorityRadioContainer { permissionedMembers?: TopicAuthorMemberWithAuthorId[]; } -const AuthorityRadioContainer = ({ +function AuthorityRadioContainer({ isPrivate, isAllPermissioned, authorizedMemberIds, @@ -32,7 +34,7 @@ const AuthorityRadioContainer = ({ setIsAllPermissioned, setAuthorizedMemberIds, permissionedMembers, -}: AuthorityRadioContainer) => { +}: AuthorityRadioContainerProps) { const { openModal, closeModal } = useContext(ModalContext); const { fetchGet } = useGet(); @@ -204,7 +206,7 @@ const AuthorityRadioContainer = ({ > - + ); -}; +} const MyInfoContainer = styled(Flex)` border: 1px solid ${({ theme }) => theme.color.lightGray}; diff --git a/frontend/src/components/MyInfo/index.tsx b/frontend/src/components/MyInfo/index.tsx index e68df6a6..6f280f80 100644 --- a/frontend/src/components/MyInfo/index.tsx +++ b/frontend/src/components/MyInfo/index.tsx @@ -1,19 +1,21 @@ +import { SyntheticEvent, useState } from 'react'; import { styled } from 'styled-components'; -import Flex from '../common/Flex'; + +import Setting from '../../assets/updateBtn.svg'; +import { DEFAULT_PROD_URL, DEFAULT_PROFILE_IMAGE } from '../../constants'; +import useToast from '../../hooks/useToast'; +import { ProfileProps } from '../../types/Profile'; import Box from '../common/Box'; -import Text from '../common/Text'; +import Button from '../common/Button'; +import Flex from '../common/Flex'; +import Image from '../common/Image'; import Space from '../common/Space'; -import { useState } from 'react'; -import { ProfileProps } from '../../types/Profile'; +import Text from '../common/Text'; import UpdateMyInfo from './UpdateMyInfo'; -import Button from '../common/Button'; -import useToast from '../../hooks/useToast'; -import { DEFAULT_PROD_URL } from '../../constants'; -import Setting from '../../assets/setting.svg'; const accessToken = localStorage.getItem('userToken'); -const MyInfo = () => { +function MyInfo() { const { showToast } = useToast(); const user = JSON.parse(localStorage.getItem('user') || '{}'); @@ -28,7 +30,7 @@ const MyInfo = () => { setIsModifyMyInfo(true); }; - const onClickLogout = async (e: React.MouseEvent) => { + const onClickLogout = async () => { try { fetch(`${DEFAULT_PROD_URL}/logout`, { method: 'POST', @@ -37,7 +39,7 @@ const MyInfo = () => { Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ - accessToken: accessToken, + accessToken, }), }); @@ -71,7 +73,14 @@ const MyInfo = () => { - + 프로필 이미지 @@ -88,7 +97,7 @@ const MyInfo = () => { ); -}; +} const MyInfoContainer = styled(Flex)` position: relative; diff --git a/frontend/src/components/PinCard/index.tsx b/frontend/src/components/PinCard/index.tsx index c1d6c951..6a58903c 100644 --- a/frontend/src/components/PinCard/index.tsx +++ b/frontend/src/components/PinCard/index.tsx @@ -1,34 +1,17 @@ import { styled } from 'styled-components'; -import Text from '../common/Text'; -import useNavigator from '../../hooks/useNavigator'; + import Box from '../common/Box'; -import Image from '../common/Image'; -import { SyntheticEvent } from 'react'; -import Space from '../common/Space'; import Flex from '../common/Flex'; -import FavoriteSVG from '../../assets/favoriteBtn_filled.svg'; -import SeeTogetherSVG from '../../assets/seeTogetherBtn_filled.svg'; -import SmallTopicPin from '../../assets/smallTopicPin.svg'; -import SmallTopicStar from '../../assets/smallTopicStar.svg'; -import { DEFAULT_TOPIC_IMAGE } from '../../constants'; -import AddSeeTogether from '../AddSeeTogether'; -import AddFavorite from '../AddFavorite'; +import Space from '../common/Space'; +import Text from '../common/Text'; export interface PinCardProps { - pinId: number; pinTitle: string; pinAddress: string; pinDescription: string; } -const PinCard = ({ - pinId, - pinTitle, - pinAddress, - pinDescription, -}: PinCardProps) => { - const { routePage } = useNavigator(); - +function PinCard({ pinTitle, pinAddress, pinDescription }: PinCardProps) { return ( @@ -53,7 +36,7 @@ const PinCard = ({ ); -}; +} const Wrapper = styled.li` width: 332px; diff --git a/frontend/src/components/PinImageContainer/index.tsx b/frontend/src/components/PinImageContainer/index.tsx index a62b9ff4..e5018a60 100644 --- a/frontend/src/components/PinImageContainer/index.tsx +++ b/frontend/src/components/PinImageContainer/index.tsx @@ -1,41 +1,180 @@ +import { Swiper, Tab } from 'map-befine-swiper'; +import { useState } from 'react'; import styled from 'styled-components'; + +import useDelete from '../../apiHooks/useDelete'; +import RemoveImageButton from '../../assets/remove_image_icon.svg'; +import { ImageModal, useModalContext } from '../../context/ImageModalContext'; +import useToast from '../../hooks/useToast'; import { ImageProps } from '../../types/Pin'; +import Button from '../common/Button'; import Image from '../common/Image'; +import Space from '../common/Space'; interface PinImageContainerProps { images: ImageProps[]; + getPinData: () => void; } +const NOT_FOUND_IMAGE = + 'https://dr702blqc4x5d.cloudfront.net/2023-map-be-fine/icon/notFound_image.svg'; -const PinImageContainer = ({ images }: PinImageContainerProps) => { - return ( - <> +function PinImageContainer({ images, getPinData }: PinImageContainerProps) { + const { fetchDelete } = useDelete(); + const { showToast } = useToast(); + const { isModalOpen, openModal, closeModal } = useModalContext(); + const [modalImage, setModalImage] = useState(''); + + const onRemovePinImage = (imageId: number) => { + const isRemoveImage = confirm('해당 이미지를 삭제하시겠습니까?'); + + if (isRemoveImage) { + fetchDelete({ + url: `/pins/images/${imageId}`, + errorMessage: '이미지 제거에 실패했습니다.', + onSuccess: () => { + showToast('info', '핀에서 이미지가 삭제 되었습니다.'); + getPinData(); + }, + }); + } + }; + + const onImageOpen = (imageUrl: string) => { + setModalImage(imageUrl); + openModal(); + }; + + if (images.length <= 3) { + return ( - {images.map( - (image, index) => - index < 3 && ( - + {images.map((image, index) => ( + + +
onImageOpen(image.imageUrl)}> - - ), +
+ onRemovePinImage(image.id)} + > + + +
+
+ ))} + {isModalOpen && ( + + + + + + + )}
- + ); + } + + return ( + + + {images.map((image, index) => ( + + +
onImageOpen(image.imageUrl)}> + +
+ onRemovePinImage(image.id)} + > + + +
+
+ ))} + {isModalOpen && ( + + + + + + + + )} +
+
); -}; +} -const FilmList = styled.ul` +const Wrapper = styled.div` width: 330px; +`; + +const FilmList = styled.ul` display: flex; - flex-direction: row; `; const ImageWrapper = styled.li` + position: relative; margin-right: 10px; `; +const RemoveImageIconWrapper = styled.div` + opacity: 0.6; + position: absolute; + right: 1px; + top: 1px; + line-height: 0; + background-color: #ffffff; + cursor: pointer; + + &:hover { + opacity: 1; + } +`; + +const ModalImageWrapper = styled.div` + width: 100%; + height: 100%; + + display: flex; + flex-direction: column; + align-items: center; + + margin: 0 auto; + overflow: hidden; +`; + +const ModalImage = styled.img` + width: 100%; + height: 100%; + + min-width: 360px; + min-height: 360px; + + display: block; +`; export default PinImageContainer; diff --git a/frontend/src/components/PinPreview/index.stories.ts b/frontend/src/components/PinPreview/index.stories.ts index c50e8b2e..65f22d69 100644 --- a/frontend/src/components/PinPreview/index.stories.ts +++ b/frontend/src/components/PinPreview/index.stories.ts @@ -1,4 +1,5 @@ -import { StoryObj, Meta } from '@storybook/react'; +import { Meta, StoryObj } from '@storybook/react'; + import PinPreview from '.'; const meta = { diff --git a/frontend/src/components/PinPreview/index.tsx b/frontend/src/components/PinPreview/index.tsx index 3ce8aa66..0ec637f3 100644 --- a/frontend/src/components/PinPreview/index.tsx +++ b/frontend/src/components/PinPreview/index.tsx @@ -1,25 +1,28 @@ +import { KeyboardEvent, useContext, useEffect, useRef, useState } from 'react'; +import { useLocation } from 'react-router-dom'; import { styled } from 'styled-components'; -import Flex from '../common/Flex'; -import Space from '../common/Space'; -import Text from '../common/Text'; + +import { TagContext } from '../../context/TagContext'; import useNavigator from '../../hooks/useNavigator'; -import { useEffect, useRef, useState, KeyboardEvent, useContext } from 'react'; import theme from '../../themes'; import Box from '../common/Box'; -import { TagContext } from '../../context/TagContext'; +import Flex from '../common/Flex'; +import Space from '../common/Space'; +import Text from '../common/Text'; export interface PinPreviewProps { idx: number; + pinId: number; + topicId: string; + urlTopicId: string; pinTitle: string; pinLocation: string; pinInformation: string; setSelectedPinId: React.Dispatch>; - pinId: number; - topicId: string; setIsEditPinDetail: React.Dispatch>; } -const PinPreview = ({ +function PinPreview({ idx, pinTitle, pinLocation, @@ -27,8 +30,10 @@ const PinPreview = ({ setSelectedPinId, pinId, topicId, + urlTopicId, setIsEditPinDetail, -}: PinPreviewProps) => { +}: PinPreviewProps) { + const { pathname } = useLocation(); const { routePage } = useNavigator(); const { tags, setTags } = useContext(TagContext); const [announceText, setAnnounceText] = useState('토픽 핀 선택'); @@ -62,7 +67,12 @@ const PinPreview = ({ setSelectedPinId(pinId); setIsEditPinDetail(false); - routePage(`/topics/${topicId}?pinDetail=${pinId}`); + if (pathname.split('/')[1] === 'topics') { + routePage(`/topics/${urlTopicId}?pinDetail=${pinId}`); + return; + } + + routePage(`/see-together/${urlTopicId}?pinDetail=${pinId}`); }; const onInputKeyDown = (e: KeyboardEvent) => { @@ -131,10 +141,10 @@ const PinPreview = ({ id="live-region" aria-live="assertive" style={{ position: 'absolute', left: '-9999px' }} - > + />
); -}; +} const MultiSelectButton = styled.input` width: 24px; diff --git a/frontend/src/components/PinsOfTopic/index.tsx b/frontend/src/components/PinsOfTopic/index.tsx index 1bd1d133..0e9622ed 100644 --- a/frontend/src/components/PinsOfTopic/index.tsx +++ b/frontend/src/components/PinsOfTopic/index.tsx @@ -1,9 +1,11 @@ import { styled } from 'styled-components'; + import { TopicDetailProps } from '../../types/Topic'; import PinPreview from '../PinPreview'; import TopicInfo from '../TopicInfo'; interface PinsOfTopicProps { + urlTopicId?: string; topicId: string; topicDetail: TopicDetailProps; setSelectedPinId: React.Dispatch>; @@ -11,13 +13,14 @@ interface PinsOfTopicProps { setTopicsFromServer: () => void; } -const PinsOfTopic = ({ +function PinsOfTopic({ + urlTopicId, topicId, topicDetail, setSelectedPinId, setIsEditPinDetail, setTopicsFromServer, -}: PinsOfTopicProps) => { +}: PinsOfTopicProps) { return ( (
  • ))}
    ); -}; +} const Wrapper = styled.ul``; diff --git a/frontend/src/components/PullPin/index.tsx b/frontend/src/components/PullPin/index.tsx index 7264d05b..5330e94b 100644 --- a/frontend/src/components/PullPin/index.tsx +++ b/frontend/src/components/PullPin/index.tsx @@ -1,9 +1,10 @@ import { styled } from 'styled-components'; + +import { TagProps } from '../../types/Tag'; import Button from '../common/Button'; import Flex from '../common/Flex'; import Space from '../common/Space'; import Tag from '../Tag'; -import { TagProps } from '../../types/Tag'; export interface MergeOrSeeTogetherProps { tags: TagProps[]; @@ -12,13 +13,13 @@ export interface MergeOrSeeTogetherProps { onClickClose: () => void; } -const PullPin = ({ +function PullPin({ tags, confirmButton, onClickConfirm, onClickClose, -}: MergeOrSeeTogetherProps) => { - if (tags.length === 0) return <>; +}: MergeOrSeeTogetherProps) { + if (tags.length === 0) return null; return ( @@ -39,9 +40,9 @@ const PullPin = ({ 외 {String(tags.length - 5)}개 ) : ( - tags.map((tag, index) => ( + tags.map((tag) => ( ); -}; +} const Wrapper = styled.section` width: 332px; @@ -100,11 +101,11 @@ const Wrapper = styled.section` border-bottom: 4px solid ${({ theme }) => theme.color.black}; @media (max-width: 1076px) { - width: calc(50vw - 40px); + width: 50vw; } @media (max-width: 744px) { - width: calc(100vw - 40px); + width: 100vw; } @media (max-width: 372px) { diff --git a/frontend/src/components/SearchBar/SearchBar.tsx b/frontend/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 00000000..cef75d51 --- /dev/null +++ b/frontend/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import styled from 'styled-components'; + +import SearchIcon from '../../assets/search.svg'; +import useNavigator from '../../hooks/useNavigator'; + +function SearchBar() { + const { routingHandlers } = useNavigator(); + + const [searchTerm, setSearchTerm] = useState(''); + + const onInputChange = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value); + }; + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + routingHandlers.search(searchTerm); + }; + + return ( + + + + + ); +} +export default SearchBar; + +const SearchBarWrapper = styled.form` + display: flex; + padding-left: 20px; + position: relative; + border-radius: 5px; + border: 1px solid #ccc; + box-shadow: 0px 1px 5px 3px rgba(0, 0, 0, 0.12); +`; + +const SearchInput = styled.input` + height: 45px; + width: 100%; + outline: none; + border: none; + border-radius: 5px; + padding: 0 60px 0 20px; + font-size: 18px; + font-weight: 500; + &:focus { + outline: none !important; + box-shadow: + inset -1px -1px rgba(0, 0, 0, 0.075), + inset -1px -1px rgba(0, 0, 0, 0.075), + inset -3px -3px rgba(255, 255, 255, 0.6), + inset -4px -4px rgba(255, 255, 255, 0.6), + inset rgba(255, 255, 255, 0.6), + inset rgba(64, 64, 64, 0.15); + } +`; + +const StyledSearchIcon = styled(SearchIcon)` + position: absolute; + top: 50%; + left: 10px; + transform: translateY(-50%); + width: 20px; + height: 20px; + fill: #ccc; +`; diff --git a/frontend/src/components/SeeTogetherCounter/index.tsx b/frontend/src/components/SeeTogetherCounter/index.tsx index 4c372916..165b62f6 100644 --- a/frontend/src/components/SeeTogetherCounter/index.tsx +++ b/frontend/src/components/SeeTogetherCounter/index.tsx @@ -1,11 +1,12 @@ import { useContext, useEffect } from 'react'; -import { SeeTogetherContext } from '../../context/SeeTogetherContext'; import { keyframes, styled } from 'styled-components'; + import { getApi } from '../../apis/getApi'; -import { TopicCardProps } from '../../types/Topic'; +import { SeeTogetherContext } from '../../context/SeeTogetherContext'; import useToast from '../../hooks/useToast'; +import { TopicCardProps } from '../../types/Topic'; -const SeeTogetherCounter = () => { +function SeeTogetherCounter() { const { seeTogetherTopics, setSeeTogetherTopics } = useContext(SeeTogetherContext); const { showToast } = useToast(); @@ -16,7 +17,7 @@ const SeeTogetherCounter = () => { if (!userToken) return; const topics = await getApi('/members/my/atlas'); - setSeeTogetherTopics(topics); + setSeeTogetherTopics(topics.map((topic) => topic.id)); } catch { showToast( 'error', @@ -29,11 +30,11 @@ const SeeTogetherCounter = () => { getSeeTogetherTopics(); }, []); - if (!seeTogetherTopics) return <>; - if (seeTogetherTopics.length === 0) return <>; + if (!seeTogetherTopics) return null; + if (seeTogetherTopics.length === 0) return null; return {seeTogetherTopics.length}; -}; +} const initAnimation = keyframes` 0% { diff --git a/frontend/src/components/Skeletons/PinPreviewSkeleton.tsx b/frontend/src/components/Skeletons/PinPreviewSkeleton.tsx index c16c77f7..2ab12c99 100644 --- a/frontend/src/components/Skeletons/PinPreviewSkeleton.tsx +++ b/frontend/src/components/Skeletons/PinPreviewSkeleton.tsx @@ -1,8 +1,9 @@ import { keyframes, styled } from 'styled-components'; + import Flex from '../common/Flex'; import Space from '../common/Space'; -const PinPreviewSkeleton = () => { +function PinPreviewSkeleton() { return ( @@ -12,7 +13,7 @@ const PinPreviewSkeleton = () => { ); -}; +} const skeletonAnimation = keyframes` from { @@ -29,7 +30,7 @@ const SkeletonTitle = styled.div` border-radius: 8px; - background: ${({ theme }) => theme.color['lightGray']}; + background: ${({ theme }) => theme.color.lightGray}; animation: ${skeletonAnimation} 1s infinite; `; diff --git a/frontend/src/components/Skeletons/PinsOfTopicSkeleton.tsx b/frontend/src/components/Skeletons/PinsOfTopicSkeleton.tsx index ffe99d46..371138c9 100644 --- a/frontend/src/components/Skeletons/PinsOfTopicSkeleton.tsx +++ b/frontend/src/components/Skeletons/PinsOfTopicSkeleton.tsx @@ -1,9 +1,9 @@ import Flex from '../common/Flex'; -import PinPreviewSkeleton from './PinPreviewSkeleton'; import Space from '../common/Space'; +import PinPreviewSkeleton from './PinPreviewSkeleton'; import TopicInfoSkeleton from './TopicInfoSkeleton'; -const PinsOfTopicSkeleton = () => { +function PinsOfTopicSkeleton() { return ( @@ -16,6 +16,6 @@ const PinsOfTopicSkeleton = () => { ); -}; +} export default PinsOfTopicSkeleton; diff --git a/frontend/src/components/Skeletons/TopicCardSkeleton.tsx b/frontend/src/components/Skeletons/TopicCardSkeleton.tsx index 27c87822..1592b62e 100644 --- a/frontend/src/components/Skeletons/TopicCardSkeleton.tsx +++ b/frontend/src/components/Skeletons/TopicCardSkeleton.tsx @@ -1,8 +1,9 @@ import { keyframes, styled } from 'styled-components'; + import Flex from '../common/Flex'; import Space from '../common/Space'; -const TopicCardSkeleton = () => { +function TopicCardSkeleton() { return ( @@ -14,7 +15,7 @@ const TopicCardSkeleton = () => {
    ); -}; +} const skeletonAnimation = keyframes` from { @@ -31,7 +32,7 @@ const SkeletonImg = styled.div` border-radius: 8px; - background: ${({ theme }) => theme.color['lightGray']}; + background: ${({ theme }) => theme.color.lightGray}; animation: ${skeletonAnimation} 1s infinite; `; @@ -41,7 +42,7 @@ const SkeletonTitle = styled.div` border-radius: 8px; - background: ${({ theme }) => theme.color['lightGray']}; + background: ${({ theme }) => theme.color.lightGray}; animation: ${skeletonAnimation} 1s infinite; `; diff --git a/frontend/src/components/Skeletons/TopicInfoSkeleton.tsx b/frontend/src/components/Skeletons/TopicInfoSkeleton.tsx index fbc218a1..21601a76 100644 --- a/frontend/src/components/Skeletons/TopicInfoSkeleton.tsx +++ b/frontend/src/components/Skeletons/TopicInfoSkeleton.tsx @@ -1,9 +1,9 @@ import { keyframes, styled } from 'styled-components'; -import Flex from '../common/Flex'; -import Space from '../common/Space'; + import Box from '../common/Box'; +import Space from '../common/Space'; -const TopicInfoSkeleton = () => { +function TopicInfoSkeleton() { return ( @@ -13,7 +13,7 @@ const TopicInfoSkeleton = () => { ); -}; +} const skeletonAnimation = keyframes` from { @@ -30,7 +30,7 @@ const SkeletonImg = styled.div` border-radius: 8px; - background: ${({ theme }) => theme.color['lightGray']}; + background: ${({ theme }) => theme.color.lightGray}; animation: ${skeletonAnimation} 1s infinite; `; @@ -40,7 +40,7 @@ const SkeletonTitle = styled.div` border-radius: 8px; - background: ${({ theme }) => theme.color['lightGray']}; + background: ${({ theme }) => theme.color.lightGray}; animation: ${skeletonAnimation} 1s infinite; `; diff --git a/frontend/src/components/Skeletons/TopicListSkeleton.tsx b/frontend/src/components/Skeletons/TopicListSkeleton.tsx index 5c30b815..79deb153 100644 --- a/frontend/src/components/Skeletons/TopicListSkeleton.tsx +++ b/frontend/src/components/Skeletons/TopicListSkeleton.tsx @@ -1,7 +1,8 @@ import { styled } from 'styled-components'; + import TopicCardSkeleton from './TopicCardSkeleton'; -const TopicCardContainerSkeleton = () => { +function TopicCardContainerSkeleton() { return ( @@ -12,7 +13,7 @@ const TopicCardContainerSkeleton = () => { ); -}; +} const Wrapper = styled.section` display: flex; diff --git a/frontend/src/components/Tag/index.tsx b/frontend/src/components/Tag/index.tsx index 4673ce35..c49817cb 100644 --- a/frontend/src/components/Tag/index.tsx +++ b/frontend/src/components/Tag/index.tsx @@ -1,4 +1,5 @@ import { styled } from 'styled-components'; + import Box from '../common/Box'; import Text from '../common/Text'; @@ -7,7 +8,7 @@ interface TagProps { tabIndex?: number; } -const Tag = ({ children, tabIndex }: TagProps) => { +function Tag({ children, tabIndex }: TagProps) { return ( { ); -}; +} const EllipsisText = styled(Text)` width: 100%; diff --git a/frontend/src/components/Toast/index.tsx b/frontend/src/components/Toast/index.tsx index 6d2ac743..7ce88121 100644 --- a/frontend/src/components/Toast/index.tsx +++ b/frontend/src/components/Toast/index.tsx @@ -1,9 +1,10 @@ import { useContext } from 'react'; import ReactDOM from 'react-dom'; import { keyframes, styled } from 'styled-components'; + +import { TOAST_SHOWTIME } from '../../constants'; import { ToastContext } from '../../context/ToastContext'; import Flex from '../common/Flex'; -import { TOAST_SHOWTIME } from '../../constants'; const asynchronousDelayTime = 50; diff --git a/frontend/src/components/TopicCard/TopicCard.stories.ts b/frontend/src/components/TopicCard/TopicCard.stories.ts index 9316789a..eccf2f72 100644 --- a/frontend/src/components/TopicCard/TopicCard.stories.ts +++ b/frontend/src/components/TopicCard/TopicCard.stories.ts @@ -1,4 +1,5 @@ -import { StoryObj, Meta } from '@storybook/react'; +import { Meta, StoryObj } from '@storybook/react'; + import TopicCard from '.'; const meta = { diff --git a/frontend/src/components/TopicCard/index.tsx b/frontend/src/components/TopicCard/index.tsx index 91f54434..3e8f6d3b 100644 --- a/frontend/src/components/TopicCard/index.tsx +++ b/frontend/src/components/TopicCard/index.tsx @@ -1,23 +1,22 @@ +import { SyntheticEvent, useContext, useState } from 'react'; import { styled } from 'styled-components'; -import Text from '../common/Text'; -import useNavigator from '../../hooks/useNavigator'; -import Box from '../common/Box'; -import Image from '../common/Image'; -import { SyntheticEvent, useContext } from 'react'; -import Space from '../common/Space'; -import Flex from '../common/Flex'; -import FavoriteSVG from '../../assets/favoriteBtn_filled.svg'; -import FavoriteNotFilledSVG from '../../assets/favoriteBtn_notFilled.svg'; + import SeeTogetherSVG from '../../assets/seeTogetherBtn_filled.svg'; import SeeTogetherNotFilledSVG from '../../assets/seeTogetherBtn_notFilled.svg'; import SmallTopicPin from '../../assets/smallTopicPin.svg'; import SmallTopicStar from '../../assets/smallTopicStar.svg'; import { DEFAULT_TOPIC_IMAGE } from '../../constants'; -import AddSeeTogether from '../AddSeeTogether'; -import AddFavorite from '../AddFavorite'; -import { TopicCardProps } from '../../types/Topic'; -import useKeyDown from '../../hooks/useKeyDown'; import { ModalContext } from '../../context/ModalContext'; +import useKeyDown from '../../hooks/useKeyDown'; +import useNavigator from '../../hooks/useNavigator'; +import useToast from '../../hooks/useToast'; +import { TopicCardProps } from '../../types/Topic'; +import AddSeeTogether from '../AddSeeTogether'; +import Box from '../common/Box'; +import Flex from '../common/Flex'; +import Image from '../common/Image'; +import Space from '../common/Space'; +import MediaText from '../common/Text/MediaText'; interface OnClickDesignatedProps { topicId: number; @@ -30,7 +29,7 @@ interface TopicCardExtendedProps extends TopicCardProps { getTopicsFromServer?: () => void; } -const TopicCard = ({ +function TopicCard({ cardType, id, image, @@ -43,10 +42,13 @@ const TopicCard = ({ isBookmarked, onClickDesignated, getTopicsFromServer, -}: TopicCardExtendedProps) => { +}: TopicCardExtendedProps) { const { routePage } = useNavigator(); const { closeModal } = useContext(ModalContext); const { elementRef, onElementKeyDown } = useKeyDown(); + const [isInNonMemberAtlas, setIsInNonMemberAtlas] = + useState(isInAtlas); + const { showToast } = useToast(); const goToSelectedTopic = () => { routePage(`/topics/${id}`, [id]); @@ -60,6 +62,15 @@ const TopicCard = ({ closeModal('newPin'); }; + const onClickIsInAtlas = () => { + setIsInNonMemberAtlas(!isInNonMemberAtlas); + if (!isInNonMemberAtlas) { + showToast('info', '모아보기에 추가했습니다.'); + return true; + } + showToast('info', '해당 지도를 모아보기에서 제외했습니다.'); + return true; + }; return ( - - + 사진 이미지) => { - e.currentTarget.src = DEFAULT_TOPIC_IMAGE; - }} + $errorDefaultSrc={DEFAULT_TOPIC_IMAGE} + radius="small" + ratio="1.6 / 1" /> - + - {name} - + - {creator} - + - + {updatedAt.split('T')[0].replaceAll('-', '.')} 업데이트 - + @@ -112,58 +128,54 @@ const TopicCard = ({ - {pinCount > 999 ? '+999' : pinCount}개 - + - {bookmarkCount > 999 ? '+999' : bookmarkCount}명 - + {cardType === 'default' && getTopicsFromServer && ( - {isInAtlas ? : } + {isInNonMemberAtlas ? ( + + ) : ( + + )} - - {isBookmarked ? : } - )}
    ); -}; +} const Wrapper = styled.li` - width: 332px; - height: 140px; cursor: pointer; - border: 1px solid ${({ theme }) => theme.color.gray}; border-radius: ${({ theme }) => theme.radius.small}; `; @@ -171,14 +183,10 @@ const ButtonWrapper = styled.div` display: flex; justify-content: space-between; position: absolute; - width: 72px; + width: 32px; - top: 100px; - left: 60px; -`; - -const TopicImage = styled(Image)` - border-radius: ${({ theme }) => theme.radius.small}; + top: 4%; + right: 4%; `; export default TopicCard; diff --git a/frontend/src/components/TopicCardContainer/index.tsx b/frontend/src/components/TopicCardContainer/index.tsx index 9041ed37..b7d7272d 100644 --- a/frontend/src/components/TopicCardContainer/index.tsx +++ b/frontend/src/components/TopicCardContainer/index.tsx @@ -1,13 +1,16 @@ +import { Swiper, Tab } from 'map-befine-swiper'; +import { useEffect, useState } from 'react'; import { styled } from 'styled-components'; -import Flex from '../common/Flex'; -import Text from '../common/Text'; + +import useGet from '../../apiHooks/useGet'; +import useKeyDown from '../../hooks/useKeyDown'; +import { TopicCardProps } from '../../types/Topic'; import Box from '../common/Box'; +import Flex from '../common/Flex'; import Space from '../common/Space'; -import { Fragment, useEffect, useState } from 'react'; -import { TopicCardProps } from '../../types/Topic'; -import useKeyDown from '../../hooks/useKeyDown'; +import Text from '../common/Text'; +import MediaText from '../common/Text/MediaText'; import TopicCard from '../TopicCard'; -import useGet from '../../apiHooks/useGet'; interface TopicCardContainerProps { url: string; @@ -16,12 +19,12 @@ interface TopicCardContainerProps { routeWhenSeeAll: () => void; } -const TopicCardContainer = ({ +function TopicCardContainer({ url, containerTitle, containerDescription, routeWhenSeeAll, -}: TopicCardContainerProps) => { +}: TopicCardContainerProps) { const [topics, setTopics] = useState(null); const { elementRef, onElementKeyDown } = useKeyDown(); const { fetchGet } = useGet(); @@ -44,23 +47,23 @@ const TopicCardContainer = ({
    - {containerTitle} - + - {containerDescription} - + - + {topics && - topics.map((topic, index) => { - return ( - index < 6 && ( - - - - ) - ); - })} - + topics.map( + (topic, index) => + index < 10 && ( + + + + + + + + ), + )} +
    ); -}; +} const PointerText = styled(Text)` cursor: pointer; `; -const TopicsWrapper = styled.ul` - display: flex; - justify-content: center; - flex-wrap: wrap; - gap: 20px; +const CustomSpace = styled.div` + min-width: 10px; + min-height: 10px; `; export default TopicCardContainer; diff --git a/frontend/src/components/TopicCardList/index.tsx b/frontend/src/components/TopicCardList/index.tsx index 74c15110..fb0931b1 100644 --- a/frontend/src/components/TopicCardList/index.tsx +++ b/frontend/src/components/TopicCardList/index.tsx @@ -1,12 +1,14 @@ import { Fragment, useEffect, useState } from 'react'; import { styled } from 'styled-components'; -import TopicCard from '../TopicCard'; -import { TopicCardProps } from '../../types/Topic'; + import useGet from '../../apiHooks/useGet'; +import { TopicCardProps } from '../../types/Topic'; +import Button from '../common/Button'; import Flex from '../common/Flex'; +import Grid from '../common/Grid'; import Space from '../common/Space'; import Text from '../common/Text'; -import Button from '../common/Button'; +import TopicCard from '../TopicCard'; interface TopicCardListProps { url: string; @@ -17,14 +19,14 @@ interface TopicCardListProps { children?: React.ReactNode; } -const TopicCardList = ({ +function TopicCardList({ url, errorMessage, commentWhenEmpty, pageCommentWhenEmpty, routePage, children, -}: TopicCardListProps) => { +}: TopicCardListProps) { const [topics, setTopics] = useState(null); const { fetchGet } = useGet(); @@ -38,7 +40,7 @@ const TopicCardList = ({ getTopicsFromServer(); }, []); - if (!topics) return <>; + if (!topics) return null; if (topics.length === 0) { return ( @@ -61,26 +63,39 @@ const TopicCardList = ({ return ( - {topics.map((topic) => ( - - - - ))} + + {topics.map((topic) => ( + + + + ))} + ); -}; +} const EmptyWrapper = styled.section` height: 240px; diff --git a/frontend/src/components/TopicInfo/UpdatedTopicInfo.tsx b/frontend/src/components/TopicInfo/UpdatedTopicInfo.tsx index 0d3eb665..3beb4d38 100644 --- a/frontend/src/components/TopicInfo/UpdatedTopicInfo.tsx +++ b/frontend/src/components/TopicInfo/UpdatedTopicInfo.tsx @@ -1,17 +1,22 @@ -import styled from 'styled-components'; -import InputContainer from '../InputContainer'; -import useFormValues from '../../hooks/useFormValues'; -import Space from '../common/Space'; -import Flex from '../common/Flex'; -import Button from '../common/Button'; import { useEffect, useState } from 'react'; +import styled from 'styled-components'; + +import useDelete from '../../apiHooks/useDelete'; import useGet from '../../apiHooks/useGet'; -import { TopicAuthorInfo } from '../../types/Topic'; -import AuthorityRadioContainer from '../AuthorityRadioContainer'; import usePost from '../../apiHooks/usePost'; -import useDelete from '../../apiHooks/useDelete'; import usePut from '../../apiHooks/usePut'; +import { putApi } from '../../apis/putApi'; +import { DEFAULT_TOPIC_IMAGE } from '../../constants'; +import useCompressImage from '../../hooks/useCompressImage'; +import useFormValues from '../../hooks/useFormValues'; import useToast from '../../hooks/useToast'; +import { TopicAuthorInfo } from '../../types/Topic'; +import Button from '../common/Button'; +import Flex from '../common/Flex'; +import ImageCommon from '../common/Image'; +import Space from '../common/Space'; +import Text from '../common/Text'; +import InputContainer from '../InputContainer'; interface UpdatedTopicInfoProp { id: number; @@ -27,14 +32,14 @@ interface FormValues { description: string; } -const UpdatedTopicInfo = ({ +function UpdatedTopicInfo({ id, image, name, description, setIsUpdate, setTopicsFromServer, -}: UpdatedTopicInfoProp) => { +}: UpdatedTopicInfoProp) { const { fetchPost } = usePost(); const { fetchGet } = useGet(); const { fetchDelete } = useDelete(); @@ -51,6 +56,8 @@ const UpdatedTopicInfo = ({ const [isPrivate, setIsPrivate] = useState(false); // 혼자 볼 지도 : 같이 볼 지도 const [isAllPermissioned, setIsAllPermissioned] = useState(true); // 모두 : 지정 인원 const [authorizedMemberIds, setAuthorizedMemberIds] = useState([]); + const [changedImages, setChangedImages] = useState(null); + const { compressImage } = useCompressImage(); const updateTopicInfo = async () => { try { @@ -78,7 +85,7 @@ const UpdatedTopicInfo = ({ // await updateTopicPermissionMembers(); // } - showToast('info', '지도를 수정하였습니다.'); + showToast('info', `${name} 지도를 수정하였습니다.`); setIsUpdate(false); setTopicsFromServer(); } catch {} @@ -123,12 +130,80 @@ const UpdatedTopicInfo = ({ ); }, []); + const onTopicImageFileChange = async ( + event: React.ChangeEvent, + ) => { + const file = event.target.files && event.target.files[0]; + const formData = new FormData(); + const currentImage = new Image(); + + if (!file) { + showToast( + 'error', + '이미지를 선택하지 않았거나 추가하신 이미지를 찾을 수 없습니다. 다시 선택해 주세요.', + ); + return; + } + + const compressedFile = await compressImage(file); + currentImage.src = URL.createObjectURL(compressedFile); + + currentImage.onload = async () => { + if (currentImage.width < 300) { + showToast( + 'error', + '이미지의 크기가 너무 작습니다. 다른 이미지를 선택해 주세요.', + ); + return; + } + formData.append('image', compressedFile); + try { + await putApi(`/topics/images/${id}`, formData); + } catch { + showToast('error', '사용할 수 없는 이미지입니다.'); + } + + const updatedImageUrl = URL.createObjectURL(compressedFile); + setChangedImages(updatedImageUrl); + showToast('info', '지도 이미지를 수정하였습니다.'); + }; + }; + return ( + + + 지도 사진 + + + 지도를 대표하는 사진을 변경할 수 있습니다. + + + ) => { + e.currentTarget.src = DEFAULT_TOPIC_IMAGE; + }} + /> + 수정 + + + + + ); -}; +} + +const Wrapper = styled.article``; + +const ImageWrapper = styled.div` + position: relative; +`; + +const ImageInputLabel = styled.label` + width: 60px; + height: 35px; + margin-bottom: 10px; + padding: 10px 10px; -const Wrapper = styled.section``; + color: ${({ theme }) => theme.color.black}; + background-color: ${({ theme }) => theme.color.lightGray}; + + font-size: ${({ theme }) => theme.fontSize.extraSmall}; + font-weight: ${({ theme }) => theme.fontWeight.bold}; + text-align: center; + + border-radius: ${({ theme }) => theme.radius.small}; + cursor: pointer; + + position: absolute; + right: 5px; + bottom: -5px; + + box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 5px 0px; + + &:hover { + filter: brightness(0.95); + } + + @media (max-width: 372px) { + width: 40px; + height: 30px; + + font-size: 8px; + } +`; + +const ImageInputButton = styled.input` + display: none; +`; + +const TopicImage = styled(ImageCommon)` + border-radius: ${({ theme }) => theme.radius.medium}; +`; export default UpdatedTopicInfo; +function compressImage(file: File) { + throw new Error('Function not implemented.'); +} diff --git a/frontend/src/components/TopicInfo/index.stories.ts b/frontend/src/components/TopicInfo/index.stories.ts index 2b13769d..2b02ac03 100644 --- a/frontend/src/components/TopicInfo/index.stories.ts +++ b/frontend/src/components/TopicInfo/index.stories.ts @@ -1,4 +1,5 @@ -import { StoryObj, Meta } from '@storybook/react'; +import { Meta, StoryObj } from '@storybook/react'; + import TopicInfo from '.'; const meta = { diff --git a/frontend/src/components/TopicInfo/index.tsx b/frontend/src/components/TopicInfo/index.tsx index ce7f5081..4635db81 100644 --- a/frontend/src/components/TopicInfo/index.tsx +++ b/frontend/src/components/TopicInfo/index.tsx @@ -1,21 +1,23 @@ -import Flex from '../common/Flex'; -import Text from '../common/Text'; -import Image from '../common/Image'; -import Space from '../common/Space'; -import useToast from '../../hooks/useToast'; +import { useEffect, useState } from 'react'; +import { styled } from 'styled-components'; + import SmallTopicPin from '../../assets/smallTopicPin.svg'; import SmallTopicStar from '../../assets/smallTopicStar.svg'; -import TopicShareUrlSVG from '../../assets/topicInfo_shareUrl.svg'; import FavoriteSVG from '../../assets/topicInfo_favoriteBtn_filled.svg'; import FavoriteNotFilledSVG from '../../assets/topicInfo_favoriteBtn_notFilled.svg'; -import SeeTogetherNotFilledSVG from '../../assets/topicInfo_seeTogetherBtn_notFilled.svg'; import SeeTogetherSVG from '../../assets/topicInfo_seeTogetherBtn_filled.svg'; +import SeeTogetherNotFilledSVG from '../../assets/topicInfo_seeTogetherBtn_notFilled.svg'; +import TopicShareUrlSVG from '../../assets/topicInfo_shareUrl.svg'; +import UpdateBtnSVG from '../../assets/updateBtn.svg'; import { DEFAULT_TOPIC_IMAGE } from '../../constants'; -import AddSeeTogether from '../AddSeeTogether'; +import useToast from '../../hooks/useToast'; import AddFavorite from '../AddFavorite'; -import { styled } from 'styled-components'; +import AddSeeTogether from '../AddSeeTogether'; import Box from '../common/Box'; -import { useState } from 'react'; +import Flex from '../common/Flex'; +import Image from '../common/Image'; +import Space from '../common/Space'; +import Text from '../common/Text'; import UpdatedTopicInfo from './UpdatedTopicInfo'; export interface TopicInfoProps { @@ -33,7 +35,7 @@ export interface TopicInfoProps { setTopicsFromServer: () => void; } -const TopicInfo = ({ +function TopicInfo({ topicId, topicImage, topicTitle, @@ -46,7 +48,7 @@ const TopicInfo = ({ isInAtlas, isBookmarked, setTopicsFromServer, -}: TopicInfoProps) => { +}: TopicInfoProps) { const [isUpdate, setIsUpdate] = useState(false); const { showToast } = useToast(); @@ -64,6 +66,15 @@ const TopicInfo = ({ } }; + const onChangeIsInAtlas = () => { + showToast('error', '비회원은 홈에서만 모아보기에 담을 수 있습니다.'); + return false; + }; + + useEffect(() => { + if (!isUpdate) setTopicsFromServer(); + }, [isUpdate]); + if (isUpdate) { return ( ) => { - e.currentTarget.src = DEFAULT_TOPIC_IMAGE; - }} + radius="medium" + $errorDefaultSrc={DEFAULT_TOPIC_IMAGE} /> @@ -118,9 +128,7 @@ const TopicInfo = ({ {canUpdate && ( - - 수정하기 - + )} @@ -147,7 +155,9 @@ const TopicInfo = ({ @@ -172,7 +182,7 @@ const TopicInfo = ({ ); -}; +} const ButtonsWrapper = styled.div` display: flex; diff --git a/frontend/src/components/common/Box/index.stories.ts b/frontend/src/components/common/Box/index.stories.ts index c3ccc1d0..4ce5cf0f 100644 --- a/frontend/src/components/common/Box/index.stories.ts +++ b/frontend/src/components/common/Box/index.stories.ts @@ -1,4 +1,5 @@ -import { StoryObj, Meta } from '@storybook/react'; +import { Meta, StoryObj } from '@storybook/react'; + import Box from '.'; const meta = { @@ -141,7 +142,7 @@ const meta = { export default meta; type Story = StoryObj; -//make a Default has background Color +// make a Default has background Color export const Default: Story = { args: { diff --git a/frontend/src/components/common/Box/index.ts b/frontend/src/components/common/Box/index.ts index c508a7c0..f6100cef 100644 --- a/frontend/src/components/common/Box/index.ts +++ b/frontend/src/components/common/Box/index.ts @@ -1,8 +1,9 @@ import { styled } from 'styled-components'; + import theme from '../../../themes'; import { colorThemeKey } from '../../../themes/color'; -import { SpaceThemeKeys } from '../../../themes/spacing'; import { radiusKey } from '../../../themes/radius'; +import { SpaceThemeKeys } from '../../../themes/spacing'; export interface BoxProps { display?: string; @@ -10,6 +11,8 @@ export interface BoxProps { height?: string; $minWidth?: string; $minHeight?: string; + $maxWidth?: string; + $maxHeight?: string; padding?: SpaceThemeKeys | string; $backgroundColor?: colorThemeKey; $backdropFilter?: string; @@ -41,6 +44,8 @@ const Box = styled.div` height: ${({ height }) => height}; min-width: ${({ $minWidth }) => $minWidth}; min-height: ${({ $minHeight }) => $minHeight}; + max-width: ${({ $maxWidth }) => $maxWidth}; + max-height: ${({ $maxHeight }) => $maxHeight}; overflow: ${({ overflow }) => overflow}; position: ${({ position }) => position}; right: ${({ right }) => right}; diff --git a/frontend/src/components/common/Button/index.stories.ts b/frontend/src/components/common/Button/index.stories.ts index 4d9f8ba9..979b71d1 100644 --- a/frontend/src/components/common/Button/index.stories.ts +++ b/frontend/src/components/common/Button/index.stories.ts @@ -1,4 +1,5 @@ import { Meta, StoryObj } from '@storybook/react'; + import Button from '.'; const meta = { diff --git a/frontend/src/components/common/Button/index.ts b/frontend/src/components/common/Button/index.ts index db953634..25146074 100644 --- a/frontend/src/components/common/Button/index.ts +++ b/frontend/src/components/common/Button/index.ts @@ -1,4 +1,5 @@ import styled from 'styled-components'; + import theme from '../../../themes'; export type ButtonVariant = 'primary' | 'secondary' | 'custom'; diff --git a/frontend/src/components/common/CheckBox/index.tsx b/frontend/src/components/common/CheckBox/index.tsx index 07783f3b..0c635fd1 100644 --- a/frontend/src/components/common/CheckBox/index.tsx +++ b/frontend/src/components/common/CheckBox/index.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import styled from 'styled-components'; + import useKeyDown from '../../../hooks/useKeyDown'; import Flex from '../Flex'; @@ -10,12 +11,7 @@ interface CheckboxProps { onChecked: (checked: boolean, id: number) => void; } -const Checkbox = ({ - id, - isAlreadyChecked, - label, - onChecked, -}: CheckboxProps) => { +function Checkbox({ id, isAlreadyChecked, label, onChecked }: CheckboxProps) { const [isChecked, setIsChecked] = useState(isAlreadyChecked); const { elementRef, onElementKeyDown } = useKeyDown(); @@ -43,7 +39,7 @@ const Checkbox = ({ ); -}; +} const CheckboxWrapper = styled(Flex)` border-bottom: 1px solid #c6c6c6; diff --git a/frontend/src/components/common/Flex/index.ts b/frontend/src/components/common/Flex/index.ts index 9f592c3a..713a0287 100644 --- a/frontend/src/components/common/Flex/index.ts +++ b/frontend/src/components/common/Flex/index.ts @@ -1,4 +1,5 @@ import { styled } from 'styled-components'; + import Box, { BoxProps } from '../Box'; interface FlexProps extends BoxProps { diff --git a/frontend/src/components/common/Grid/index.ts b/frontend/src/components/common/Grid/index.ts new file mode 100644 index 00000000..985456af --- /dev/null +++ b/frontend/src/components/common/Grid/index.ts @@ -0,0 +1,40 @@ +import { CSSProperties } from 'react'; +import styled, { css } from 'styled-components'; + +import Box, { BoxProps } from '../Box'; + +interface GridProps extends BoxProps { + rows: CSSProperties['gridRow']; + columns: CSSProperties['gridColumn']; + gap?: CSSProperties['gap']; + $mediaQueries?: [number, number | string][]; +} + +const convertColumnsWithMediaQuery = ( + mediaQueries: [number, number | string][], +) => + mediaQueries.map( + ([condition, value]) => css` + @media (max-width: ${condition}px) { + grid-template-columns: ${typeof value === 'number' + ? `repeat(${value}, minmax(auto, 1fr))` + : value}; + } + `, + ); + +const Grid = styled(Box)` + display: grid; + grid-template-rows: ${({ rows }) => + typeof rows === 'number' ? `repeat(${rows}, minmax(auto, 1fr))` : rows}; + grid-template-columns: ${({ columns }) => + typeof columns === 'number' + ? `repeat(${columns}, minmax(auto, 1fr))` + : columns}; + gap: ${({ gap }) => (typeof gap === 'number' ? `${gap}px` : gap)}; + + ${({ $mediaQueries }) => + $mediaQueries && convertColumnsWithMediaQuery($mediaQueries)} +`; + +export default Grid; diff --git a/frontend/src/components/common/Image/index.ts b/frontend/src/components/common/Image/index.ts deleted file mode 100644 index 1b128abc..00000000 --- a/frontend/src/components/common/Image/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ImgHTMLAttributes, SyntheticEvent } from 'react'; -import { styled } from 'styled-components'; - -interface ImageProps extends ImgHTMLAttributes { - $objectFit?: string; -} - -const Image = styled.img` - display: block; - width: ${({ width }) => width}; - height: ${({ height }) => height}; - object-fit: ${({ $objectFit }) => $objectFit}; -`; - -export default Image; diff --git a/frontend/src/components/common/Image/index.tsx b/frontend/src/components/common/Image/index.tsx new file mode 100644 index 00000000..54d1b394 --- /dev/null +++ b/frontend/src/components/common/Image/index.tsx @@ -0,0 +1,47 @@ +import { ImgHTMLAttributes, SyntheticEvent } from 'react'; +import { styled } from 'styled-components'; + +interface ImageProps extends ImgHTMLAttributes { + width?: string; + height?: string; + $errorDefaultSrc?: string; + $objectFit?: string; + radius?: 'small' | 'medium' | '50%'; + ratio?: string; +} + +export default function Image({ + width, + height, + src, + alt, + $objectFit = 'cover', + $errorDefaultSrc, + radius, + ratio, +}: ImageProps) { + return ( + ) => { + if ($errorDefaultSrc) e.currentTarget.src = $errorDefaultSrc; + }} + /> + ); +} + +const StyledImage = styled.img>` + display: block; + width: ${({ width }) => width}; + height: ${({ height }) => height}; + object-fit: ${({ $objectFit }) => $objectFit}; + border-radius: ${({ radius, theme }) => + radius && radius === '50%' ? '50%' : theme.radius[`${radius}`]}; + aspect-ratio: ${({ ratio }) => ratio}; +`; diff --git a/frontend/src/components/common/Input/Autocomplete.tsx b/frontend/src/components/common/Input/Autocomplete.tsx new file mode 100644 index 00000000..c790e166 --- /dev/null +++ b/frontend/src/components/common/Input/Autocomplete.tsx @@ -0,0 +1,133 @@ +/* eslint-disable react/function-component-definition */ +import { memo, useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; + +import { getPoiApi } from '../../../apis/getPoiApi'; +import { Poi } from '../../../types/Poi'; +import Text from '../Text'; +import Input from '.'; + +interface AutocompleteProps { + defaultValue?: string; + onSuggestionSelected: (suggestion: Poi) => void; +} + +const Autocomplete = ({ + defaultValue = '', + onSuggestionSelected, +}: AutocompleteProps) => { + const [inputValue, setInputValue] = useState(defaultValue); + const [suggestions, setSuggestions] = useState([]); + const [selectedSuggestion, setSelectedSuggestion] = useState< + Poi['name'] | null + >(null); + + const debounceTimeoutRef = useRef(null); + const fetchData = async (query: string) => { + try { + const fetchedSuggestions = await getPoiApi(query); + + if (!fetchedSuggestions) + throw new Error('추천 검색어를 불러오지 못했습니다.'); + + setSuggestions(fetchedSuggestions.searchPoiInfo.pois.poi); + } catch (error) { + setSuggestions([]); + console.error(error); + } + }; + + const onInputChange = (e: React.ChangeEvent) => { + if (e.target.value.trim() === '') { + setSuggestions([]); + setInputValue(''); + return; + } + + setInputValue(e.target.value); + + if (debounceTimeoutRef.current !== null) { + clearTimeout(debounceTimeoutRef.current); + } + + debounceTimeoutRef.current = setTimeout( + () => fetchData(e.target.value), + 500, + ); + }; + + const onClickSuggestion = (suggestion: Poi) => { + const { name } = suggestion; + setInputValue(name); + setSelectedSuggestion(name); + onSuggestionSelected(suggestion); + }; + + useEffect(() => { + setInputValue(defaultValue); + }, [defaultValue]); + + return ( + <> + setSelectedSuggestion(null)} + /> + + {!selectedSuggestion && ( + + {suggestions?.map((suggestion: Poi, index: number) => ( + { + onClickSuggestion(suggestion); + }} + > + {suggestion.name} +
    + {suggestion.upperAddrName} {suggestion.middleAddrName}{' '} + {suggestion.roadName} +
    +
    + ))} +
    + )} + + ); +}; + +export default memo(Autocomplete); + +const AutocompleteInput = styled(Input)` + width: 100%; +`; + +const SuggestionsList = styled.ul` + border: 1px solid #ccc; + border-top: none; + border-bottom: none; + border-radius: 4px; + box-shadow: 0px 4px 5px -2px rgba(0, 0, 0, 0.3); +`; + +const SuggestionItem = styled.li` + padding: ${({ theme }) => theme.spacing['2']}; + cursor: pointer; + + &:hover { + background-color: #f7f7f7; + } +`; + +const Address = styled(Text)``; + +const Description = styled.div` + font-size: ${({ theme }) => theme.fontSize.small}; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; diff --git a/frontend/src/components/common/Input/ReplyComment.tsx b/frontend/src/components/common/Input/ReplyComment.tsx new file mode 100644 index 00000000..f40065d2 --- /dev/null +++ b/frontend/src/components/common/Input/ReplyComment.tsx @@ -0,0 +1,35 @@ +import SingleComment from './SingleComment'; + +interface ReplyCommentProps { + commentList: Comment[]; + pageTotalCommentList: Comment[]; + depth: number; + refetch: (pinId: number) => Promise; +} + +function ReplyComment({ + commentList, + pageTotalCommentList, + depth, + refetch, +}: ReplyCommentProps) { + if (depth === 2) return null; + return ( + <> + {commentList.length > 0 && + commentList.map((comment) => ( + <> + + + ))} + + ); +} + +export default ReplyComment; diff --git a/frontend/src/components/common/Input/SingleComment.tsx b/frontend/src/components/common/Input/SingleComment.tsx new file mode 100644 index 00000000..c8fb143f --- /dev/null +++ b/frontend/src/components/common/Input/SingleComment.tsx @@ -0,0 +1,275 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import { useState } from 'react'; +import styled from 'styled-components'; + +import { deleteApi } from '../../../apis/deleteApi'; +import { postApi } from '../../../apis/postApi'; +import { putApi } from '../../../apis/putApi'; +import useToast from '../../../hooks/useToast'; +import { ConfirmCommentButton, CustomInput } from '../../../pages/PinDetail'; +import Flex from '../Flex'; +import Text from '../Text'; +import ReplyComment from './ReplyComment'; + +const localStorageUser = localStorage?.getItem('user'); +const user = JSON.parse(localStorageUser || '{}'); +// { 댓글, 댓글목록, 전체목록, depth = 0 } +function SingleComment({ + comment, + commentList, + totalList, + depth = 0, + refetch, +}: any) { + const [replyOpen, setReplyOpen] = useState(false); + const [seeMore, setSeeMore] = useState(false); + const [newComment, setNewComment] = useState(''); + const { showToast } = useToast(); + const [isEditing, setIsEditing] = useState(false); + const [content, setContent] = useState(comment.content); + const params = new URLSearchParams(window.location.search); + const pinDetail = params.get('pinDetail'); + + const toggleReplyOpen = () => { + setReplyOpen((prev) => !prev); + }; + const toggleSeeMore = () => { + setSeeMore((prev) => !prev); + }; + + const replyList = commentList?.filter( + (curComment: any) => curComment.parentPinCommentId === comment.id, + ); + const replyCount = replyList.length; + + const onClickCommentBtn = async (e: React.MouseEvent) => { + e.stopPropagation(); + + try { + // 댓글 추가 + // comment 값이랑 추가 정보 body에 담아서 보내기 + + await postApi( + `/pins/comments`, + { + pinId: Number(pinDetail), + content: newComment, + parentPinCommentId: comment.id, + }, + 'application/json', + ); + await refetch(Number(pinDetail)); + setReplyOpen(false); + setNewComment(''); + showToast('info', '댓글이 추가되었습니다.'); + } catch (e) { + showToast('error', '댓글을 다시 작성해주세요'); + } + }; + + const onClickDeleteBtn = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + // 댓글 삭제 + await deleteApi(`/pins/comments/${comment.id}`); + await refetch(Number(pinDetail)); + showToast('info', '댓글이 삭제되었습니다.'); + } catch (e) { + console.error(e); + showToast('error', '댓글을 다시 작성해주세요'); + } + }; + + const onContentChange = (e: React.ChangeEvent) => { + setContent(e.target.value); + }; + + const onClickModifyConfirmBtn = async ( + e: React.MouseEvent, + ) => { + e.stopPropagation(); + try { + // 댓글 수정 + await putApi(`/pins/comments/${comment.id}`, { + content, + }); + await refetch(Number(pinDetail)); + setIsEditing(false); + showToast('info', '댓글이 수정되었습니다.'); + } catch (e) { + console.error(e); + showToast('error', '댓글을 다시 작성해주세요'); + } + }; + + const onClickModifyBtn = () => { + setIsEditing((prev) => !prev); + }; + + return ( + + + + + +
    + + + @{comment.creator} + + +
    + {comment.canChange && ( + + + 수정 + + + 삭제 + + + )} +
    + + {isEditing ? ( +
    + + + 등록 + +
    + ) : ( + + {comment.content} + + )} +
    +
    + {depth === 1 ? null : ( +
    + + 답글 작성 + +
    + )} + {replyOpen && ( +
    + +
    + setNewComment(e.target.value)} + placeholder="댓글 추가" + // onClick={toggleReplyOpen} + /> + + 등록 + +
    +
    + )} +
    + {replyCount > 0 && ( + + + + {seeMore ? '\u25B2' : '\u25BC'} 답글 {replyCount}개 + + + )} +
    +
    + + {seeMore && ( + + )} +
    + ); +} + +export default SingleComment; + +const CommentWrapper = styled.li<{ depth: number }>` + margin-left: ${(props) => props.depth * 20}px; + margin-top: 12px; + list-style: none; +`; + +export const ProfileImage = styled.img` + display: block; + + border-radius: 50%; +`; + +const CommentInfo = styled.div` + flex: 1; +`; + +const Writer = styled.div` + white-space: nowrap; +`; + +const Content = styled.div` + overflow-wrap: anywhere; +`; + +const MoreReplyButton = styled.button` + padding: 2px; + border: none; + background: none; + border-radius: 8px; + color: black; + font-weight: 600; + &:hover { + background: gray; + cursor: pointer; + } +`; diff --git a/frontend/src/components/common/Input/index.stories.ts b/frontend/src/components/common/Input/index.stories.ts index 2879a4bd..a570c185 100644 --- a/frontend/src/components/common/Input/index.stories.ts +++ b/frontend/src/components/common/Input/index.stories.ts @@ -1,4 +1,5 @@ import { Meta, StoryObj } from '@storybook/react'; + import Input from '.'; const meta = { diff --git a/frontend/src/components/common/Space/MediaSpace.ts b/frontend/src/components/common/Space/MediaSpace.ts new file mode 100644 index 00000000..2ec52018 --- /dev/null +++ b/frontend/src/components/common/Space/MediaSpace.ts @@ -0,0 +1,21 @@ +import { styled } from 'styled-components'; + +import theme from '../../../themes'; +import Space from '.'; + +const getSmallerSpaceSize = (currentIndex: number) => { + const sizeKeys = theme.spacing; + + if (currentIndex === 0) return sizeKeys[currentIndex]; + + return sizeKeys[currentIndex - 1]; +}; + +const MediaSpace = styled(Space)` + @media (max-width: 744px) { + min-width: ${({ size }) => getSmallerSpaceSize(size)}; + min-height: ${({ size }) => getSmallerSpaceSize(size)}; + } +`; + +export default MediaSpace; diff --git a/frontend/src/components/common/Space/index.ts b/frontend/src/components/common/Space/index.ts index 97f00885..0ecf7c38 100644 --- a/frontend/src/components/common/Space/index.ts +++ b/frontend/src/components/common/Space/index.ts @@ -1,4 +1,5 @@ import { styled } from 'styled-components'; + import spacing from '../../../themes/spacing'; interface SpaceProps { diff --git a/frontend/src/components/common/Text/MediaText.ts b/frontend/src/components/common/Text/MediaText.ts new file mode 100644 index 00000000..272c3061 --- /dev/null +++ b/frontend/src/components/common/Text/MediaText.ts @@ -0,0 +1,25 @@ +import styled from 'styled-components'; + +import theme from '../../../themes'; +import { fontSizeThemeKey } from '../../../themes/fontSize'; +import Text from './index'; + +const getSmallerFontSize = (currentSize: fontSizeThemeKey) => { + const sizeKeys = Object.keys(theme.fontSize) as fontSizeThemeKey[]; + const currentIndex = sizeKeys.indexOf(currentSize); + + if (currentIndex === -1 || currentIndex === sizeKeys.length - 1) { + return currentSize; + } + + return sizeKeys[currentIndex + 1]; +}; + +const MediaText = styled(Text)` + @media (max-width: 744px) { + font-size: ${({ $fontSize }) => + theme.fontSize[getSmallerFontSize($fontSize)]}; + } +`; + +export default MediaText; diff --git a/frontend/src/components/common/Text/index.stories.ts b/frontend/src/components/common/Text/index.stories.ts index e1e7ad32..03a2b6de 100644 --- a/frontend/src/components/common/Text/index.stories.ts +++ b/frontend/src/components/common/Text/index.stories.ts @@ -1,4 +1,5 @@ import { Meta, StoryObj } from '@storybook/react'; + import Text from '.'; const meta = { diff --git a/frontend/src/components/common/Text/index.ts b/frontend/src/components/common/Text/index.ts index 51e42a9a..f27dc568 100644 --- a/frontend/src/components/common/Text/index.ts +++ b/frontend/src/components/common/Text/index.ts @@ -1,15 +1,17 @@ +import { ReactNode } from 'react'; import { styled } from 'styled-components'; + +import theme from '../../../themes'; import { colorThemeKey } from '../../../themes/color'; import { fontSizeThemeKey } from '../../../themes/fontSize'; import { fontWeightThemeKey } from '../../../themes/fontWeight'; -import theme from '../../../themes'; export interface TextProps { color: colorThemeKey; $fontSize: fontSizeThemeKey; $fontWeight: fontWeightThemeKey; $textAlign?: string; - children?: React.ReactNode; + children?: ReactNode; } const Text = styled.span` diff --git a/frontend/src/components/common/Textarea/index.stories.ts b/frontend/src/components/common/Textarea/index.stories.ts index f2b6aa25..3043fd8a 100644 --- a/frontend/src/components/common/Textarea/index.stories.ts +++ b/frontend/src/components/common/Textarea/index.stories.ts @@ -1,4 +1,5 @@ import { Meta, StoryObj } from '@storybook/react'; + import Textarea from '.'; const meta = { diff --git a/frontend/src/components/common/Tooltip/index.tsx b/frontend/src/components/common/Tooltip/index.tsx deleted file mode 100644 index 6651dc27..00000000 --- a/frontend/src/components/common/Tooltip/index.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { ReactNode } from 'react'; -import styled from 'styled-components'; - -const TooltipContainer = styled.div` - display: flex; - justify-content: center; -`; - -const TooltipText = styled.div<{ position: string }>` - background-color: ${({ theme }) => theme.color.darkGray}; - border-radius: ${({ theme }) => theme.radius.small}; - bottom: ${({ position }) => (position === 'top' ? '150%' : '')}; - color: #fff; - opacity: 0; - padding: ${({ theme }) => `${theme.spacing['2']} ${theme.spacing['3']}`}; - position: absolute; - text-align: center; - visibility: hidden; - z-index: 1; - - ${({ position }) => (position === 'top' ? 'translateX(-50%)' : '')}; - - ${TooltipContainer}:hover & { - opacity: 1; - visibility: visible; - - animation: fadeout 2s; - -moz-animation: fadeout 2s; /* Firefox */ - -webkit-animation: fadeout 2s; /* Safari and Chrome */ - -o-animation: fadeout 2s; /* Opera */ - animation-fill-mode: forwards; - } - @keyframes fadeout { - from { - opacity: 1; - } - to { - opacity: 0; - } - } - @-moz-keyframes fadeout { - /* Firefox */ - from { - opacity: 1; - } - to { - opacity: 0; - } - } - @-webkit-keyframes fadeout { - /* Safari and Chrome */ - from { - opacity: 1; - } - to { - opacity: 0; - } - } - @-o-keyframes fadeout { - /* Opera */ - from { - opacity: 1; - } - to { - opacity: 0; - } - } -`; - -interface TooltipProps { - content: string; - position?: 'top' | 'bottom' | 'right' | 'left'; - children: ReactNode; -} - -const Tooltip = ({ children, content, position = 'top' }: TooltipProps) => { - return ( - - {children} - {content} - - ); -}; - -export default Tooltip; diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts index 536f24f6..ca04016c 100644 --- a/frontend/src/constants/index.ts +++ b/frontend/src/constants/index.ts @@ -5,7 +5,9 @@ export const SIDEBAR = '372px'; export const LAYOUT_PADDING = '40px'; export const DEFAULT_TOPIC_IMAGE = - 'https://velog.velcdn.com/images/semnil5202/post/37f3bcb9-0b07-4100-85f6-f1d5ad037c14/image.svg'; + 'https://dr702blqc4x5d.cloudfront.net/2023-map-be-fine/icon/topic_defaultImage.svg'; +export const DEFAULT_PROFILE_IMAGE = + 'https://dr702blqc4x5d.cloudfront.net/2023-map-be-fine/icon/profile_defaultImage.svg'; export const DEFAULT_PROD_URL = process.env.APP_URL || 'https://mapbefine.kro.kr/api'; diff --git a/frontend/src/constants/pinImage.ts b/frontend/src/constants/pinImage.ts index 71a9473e..3473a659 100644 --- a/frontend/src/constants/pinImage.ts +++ b/frontend/src/constants/pinImage.ts @@ -2,14 +2,76 @@ interface PinImageMap { [key: number]: string; } +export const USER_LOCATION_IMAGE = ` + + + +`; + export const pinImageMap: PinImageMap = { - 1: 'https://velog.velcdn.com/images/afds4567/post/077287bd-6abb-49b5-843a-0a12d9a197a9/image.svg', - 2: 'https://velog.velcdn.com/images/afds4567/post/5c849a0c-021b-4cfe-80f9-8fd552fa3987/image.svg', - 3: 'https://velog.velcdn.com/images/afds4567/post/1281de8f-380e-4ef5-a725-0f8feb1ecd08/image.svg', - 4: 'https://velog.velcdn.com/images/afds4567/post/d2835034-afe7-4a79-ae62-0e7520bec661/image.svg', - 5: 'https://velog.velcdn.com/images/afds4567/post/214b712a-f061-4530-ae12-8841224e4dc0/image.svg', - 6: 'https://velog.velcdn.com/images/afds4567/post/7dbe4728-c40a-4efe-83ac-57e2f066843b/image.svg', - 7: 'https://velog.velcdn.com/images/afds4567/post/65b642cb-222b-4936-88ce-80dc5ce3fb3f/image.svg', + 1: ` + + + + + + + +`, + 2: ` + + + + + + + +`, + 3: ` + + + + + + + +`, + 4: ` + + + + + + + +`, + 5: ` + + + + + + + +`, + 6: ` + + + + + + + +`, + 7: ` + + + + + + + +`, }; export const pinColors: PinImageMap = { diff --git a/frontend/src/constants/responsive.ts b/frontend/src/constants/responsive.ts index 2e41cf3d..f360e297 100644 --- a/frontend/src/constants/responsive.ts +++ b/frontend/src/constants/responsive.ts @@ -1,13 +1,11 @@ import { css } from 'styled-components'; -export const setFullScreenResponsive = () => { - return css` - @media (max-width: 1076px) { - width: 684px; - } +export const setFullScreenResponsive = () => css` + @media (max-width: 1076px) { + width: 684px; + } - @media (max-width: 724px) { - width: 332px; - } - `; -}; + @media (max-width: 724px) { + width: 332px; + } +`; diff --git a/frontend/src/context/CoordinatesContext.tsx b/frontend/src/context/CoordinatesContext.tsx index ab409078..88646d6d 100644 --- a/frontend/src/context/CoordinatesContext.tsx +++ b/frontend/src/context/CoordinatesContext.tsx @@ -31,7 +31,7 @@ interface Props { children: JSX.Element | JSX.Element[]; } -const CoordinatesProvider = ({ children }: Props): JSX.Element => { +function CoordinatesProvider({ children }: Props): JSX.Element { const [coordinates, setCoordinates] = useState([ { latitude: 37.5055, longitude: 127.0509 }, ]); @@ -60,6 +60,6 @@ const CoordinatesProvider = ({ children }: Props): JSX.Element => { {children} ); -}; +} export default CoordinatesProvider; diff --git a/frontend/src/context/AbsoluteModalContext.tsx b/frontend/src/context/ImageModalContext.tsx similarity index 87% rename from frontend/src/context/AbsoluteModalContext.tsx rename to frontend/src/context/ImageModalContext.tsx index 64fb154e..371ae923 100644 --- a/frontend/src/context/AbsoluteModalContext.tsx +++ b/frontend/src/context/ImageModalContext.tsx @@ -8,12 +8,12 @@ import React, { import ReactDOM from 'react-dom'; import { styled } from 'styled-components'; -interface ModalPortalProps { +interface ImageModalProps { children: ReactChild; closeModalHandler: () => void; } -export const ModalPortal = (props: ModalPortalProps) => { +export const ImageModal = (props: ImageModalProps) => { const $modalRoot = document.getElementById('modal-root') as HTMLElement; const modalRef = useRef(null); @@ -49,13 +49,11 @@ export const ModalPortal = (props: ModalPortalProps) => { > {props.children} , - $modalRoot ? $modalRoot : document.body, + $modalRoot || document.body, ); }; const ModalContainer = styled.dialog` - width: 600px; - max-height: 600px; padding: 32px 16px; display: flex; @@ -87,7 +85,7 @@ export const useModalContext = () => { export const ModalContext = React.createContext(null); -const ModalContextProvider = (props: { children: React.ReactNode }) => { +function ImageModalContextProvider(props: { children: React.ReactNode }) { const [isModalOpen, setIsModalOpen] = useState(false); const openModal = () => { @@ -109,6 +107,6 @@ const ModalContextProvider = (props: { children: React.ReactNode }) => { {props.children} ); -}; +} -export default ModalContextProvider; +export default ImageModalContextProvider; diff --git a/frontend/src/context/LayoutWidthContext.tsx b/frontend/src/context/LayoutWidthContext.tsx index 259d2192..c59307d5 100644 --- a/frontend/src/context/LayoutWidthContext.tsx +++ b/frontend/src/context/LayoutWidthContext.tsx @@ -1,8 +1,8 @@ import { + createContext, Dispatch, ReactNode, SetStateAction, - createContext, useState, } from 'react'; @@ -22,7 +22,7 @@ export const LayoutWidthContext = createContext({ setWidth: () => {}, }); -const LayoutWidthProvider = ({ children }: LayoutWidthProviderProps) => { +function LayoutWidthProvider({ children }: LayoutWidthProviderProps) { const [width, setWidth] = useState('100vw'); return ( @@ -35,6 +35,6 @@ const LayoutWidthProvider = ({ children }: LayoutWidthProviderProps) => { {children} ); -}; +} export default LayoutWidthProvider; diff --git a/frontend/src/context/MarkerContext.tsx b/frontend/src/context/MarkerContext.tsx index 6fb2cdcf..2410c0e3 100644 --- a/frontend/src/context/MarkerContext.tsx +++ b/frontend/src/context/MarkerContext.tsx @@ -1,8 +1,9 @@ import { createContext, useContext, useState } from 'react'; -import { Coordinate, CoordinatesContext } from './CoordinatesContext'; -import { useParams } from 'react-router-dom'; -import useNavigator from '../hooks/useNavigator'; +import { useLocation, useParams } from 'react-router-dom'; + import { pinColors, pinImageMap } from '../constants/pinImage'; +import useNavigator from '../hooks/useNavigator'; +import { Coordinate, CoordinatesContext } from './CoordinatesContext'; type MarkerContextType = { markers: Marker[]; @@ -32,7 +33,7 @@ interface Props { children: JSX.Element | JSX.Element[]; } -const MarkerProvider = ({ children }: Props): JSX.Element => { +function MarkerProvider({ children }: Props): JSX.Element { const { Tmapv3 } = window; const [markers, setMarkers] = useState([]); const [infoWindows, setInfoWindows] = useState(null); @@ -40,18 +41,18 @@ const MarkerProvider = ({ children }: Props): JSX.Element => { const { coordinates, clickedCoordinate } = useContext(CoordinatesContext); const { topicId } = useParams<{ topicId: string }>(); const { routePage } = useNavigator(); + const { pathname } = useLocation(); const createMarker = ( coordinate: Coordinate, map: TMap, markerType: number, - ) => { - return new Tmapv3.Marker({ + ) => + new Tmapv3.Marker({ position: new Tmapv3.LatLng(coordinate.latitude, coordinate.longitude), - icon: pinImageMap[markerType + 1], + iconHTML: pinImageMap[markerType + 1], map, }); - }; // 현재 클릭된 좌표의 마커 생성 const displayClickedMarker = (map: TMap) => { @@ -70,24 +71,29 @@ const MarkerProvider = ({ children }: Props): JSX.Element => { setClickedMarker(marker); }; - //coordinates를 받아서 marker를 생성하고, marker를 markers 배열에 추가 + // coordinates를 받아서 marker를 생성하고, marker를 markers 배열에 추가 const createMarkers = (map: TMap) => { let markerType = -1; let currentTopicId = '-1'; - let newMarkers = coordinates.map((coordinate: any) => { + const newMarkers = coordinates.map((coordinate: any) => { if (currentTopicId !== coordinate.topicId) { markerType = (markerType + 1) % 7; currentTopicId = coordinate.topicId; } - let marker = createMarker(coordinate, map, markerType); + const marker = createMarker(coordinate, map, markerType); marker.id = String(coordinate.id); return marker; }); newMarkers.forEach((marker: Marker) => { marker.on('click', () => { - routePage(`/topics/${topicId}?pinDetail=${marker.id}`); + if (pathname.split('/')[1] === 'topics') { + routePage(`/topics/${topicId}?pinDetail=${marker.id}`); + return; + } + + routePage(`/see-together/${topicId}?pinDetail=${marker.id}`); }); }); setMarkers(newMarkers); @@ -112,7 +118,7 @@ const MarkerProvider = ({ children }: Props): JSX.Element => { };" >${coordinate.pinName}`, offset: new Tmapv3.Point(0, -60), type: 2, - map: map, + map, }); return infoWindow; }); @@ -145,6 +151,6 @@ const MarkerProvider = ({ children }: Props): JSX.Element => { {children} ); -}; +} export default MarkerProvider; diff --git a/frontend/src/context/ModalContext.tsx b/frontend/src/context/ModalContext.tsx index 118c9add..1b302c03 100644 --- a/frontend/src/context/ModalContext.tsx +++ b/frontend/src/context/ModalContext.tsx @@ -20,7 +20,7 @@ export const ModalContext = createContext({ closeModal: () => {}, }); -const ModalProvider = ({ children }: ModalProviderProps) => { +function ModalProvider({ children }: ModalProviderProps) { const [modalOpens, setModalOpens] = useState({}); const openModal = (key: string) => { @@ -48,6 +48,6 @@ const ModalProvider = ({ children }: ModalProviderProps) => { {children} ); -}; +} export default ModalProvider; diff --git a/frontend/src/context/NavbarHighlightsContext.tsx b/frontend/src/context/NavbarHighlightsContext.tsx index 95db3879..9d87796f 100644 --- a/frontend/src/context/NavbarHighlightsContext.tsx +++ b/frontend/src/context/NavbarHighlightsContext.tsx @@ -1,8 +1,8 @@ import { + createContext, Dispatch, ReactNode, SetStateAction, - createContext, useState, } from 'react'; @@ -35,9 +35,7 @@ export const NavbarHighlightsContext = createContext<{ setNavbarHighlights: () => {}, }); -const NavbarHighlightsProvider = ({ - children, -}: NavbarHighlightsProviderProps) => { +function NavbarHighlightsProvider({ children }: NavbarHighlightsProviderProps) { const [navbarHighlights, setNavbarHighlights] = useState({ home: true, seeTogether: false, @@ -53,6 +51,6 @@ const NavbarHighlightsProvider = ({ {children} ); -}; +} export default NavbarHighlightsProvider; diff --git a/frontend/src/context/SeeTogetherContext.tsx b/frontend/src/context/SeeTogetherContext.tsx index 81af69af..67a5d917 100644 --- a/frontend/src/context/SeeTogetherContext.tsx +++ b/frontend/src/context/SeeTogetherContext.tsx @@ -1,15 +1,14 @@ import { + createContext, Dispatch, ReactNode, SetStateAction, - createContext, useState, } from 'react'; -import { TopicCardProps } from '../types/Topic'; interface SeeTogetherContextProps { - seeTogetherTopics: TopicCardProps[] | null; - setSeeTogetherTopics: Dispatch>; + seeTogetherTopics: number[] | null; + setSeeTogetherTopics: Dispatch>; } interface SeeTogetherProviderProps { @@ -21,10 +20,8 @@ export const SeeTogetherContext = createContext({ setSeeTogetherTopics: () => {}, }); -const SeeTogetherProvider = ({ children }: SeeTogetherProviderProps) => { - const [seeTogetherTopics, setSeeTogetherTopics] = useState< - TopicCardProps[] | null - >(null); +function SeeTogetherProvider({ children }: SeeTogetherProviderProps) { + const [seeTogetherTopics, setSeeTogetherTopics] = useState([]); return ( { {children} ); -}; +} export default SeeTogetherProvider; diff --git a/frontend/src/context/TagContext.tsx b/frontend/src/context/TagContext.tsx index 9d7cafc6..f0426cf0 100644 --- a/frontend/src/context/TagContext.tsx +++ b/frontend/src/context/TagContext.tsx @@ -1,10 +1,11 @@ import { + createContext, Dispatch, ReactNode, SetStateAction, - createContext, useState, } from 'react'; + import { TagProps } from '../types/Tag'; interface TagContextProps { @@ -21,7 +22,7 @@ export const TagContext = createContext({ setTags: () => {}, }); -const TagProvider = ({ children }: TagProviderProps) => { +function TagProvider({ children }: TagProviderProps) { const [tags, setTags] = useState([]); return ( @@ -34,6 +35,6 @@ const TagProvider = ({ children }: TagProviderProps) => { {children} ); -}; +} export default TagProvider; diff --git a/frontend/src/context/ToastContext.tsx b/frontend/src/context/ToastContext.tsx index f56d6f63..0db699e1 100644 --- a/frontend/src/context/ToastContext.tsx +++ b/frontend/src/context/ToastContext.tsx @@ -1,10 +1,11 @@ import { + createContext, Dispatch, ReactNode, SetStateAction, - createContext, useState, } from 'react'; + import ToastProps from '../types/Toast'; interface ToastContextProps { @@ -21,7 +22,7 @@ export const ToastContext = createContext({ setToast: () => {}, }); -const ToastProvider = ({ children }: ToastProviderProps) => { +function ToastProvider({ children }: ToastProviderProps) { const [toast, setToast] = useState({ show: false, type: 'info', @@ -38,6 +39,6 @@ const ToastProvider = ({ children }: ToastProviderProps) => { {children} ); -}; +} export default ToastProvider; diff --git a/frontend/src/hooks/useAnimateClickedPin.ts b/frontend/src/hooks/useAnimateClickedPin.ts index 1d0d0908..1002af84 100644 --- a/frontend/src/hooks/useAnimateClickedPin.ts +++ b/frontend/src/hooks/useAnimateClickedPin.ts @@ -1,19 +1,43 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; -const useAnimateClickedPin = (map: TMap | null, markers: Marker[]) => { +const useAnimateClickedPin = () => { const queryParams = new URLSearchParams(location.search); + const [checkQueryParams, setCheckQueryParams] = useState(queryParams); - useEffect(() => { - if (queryParams.has('pinDetail')) { - const pinId = queryParams.get('pinDetail'); - const marker = markers.find((marker: Marker) => marker.id === pinId); + const onFocusClickedPin = (map: TMap | null, markers: Marker[]) => { + useEffect(() => { + const currentQueryParams = new URLSearchParams(location.search); + + if (checkQueryParams === null) { + if (!map) return; + const pinId = queryParams.get('pinDetail'); + const marker = markers.find((marker: Marker) => marker.id === pinId); + + if (!marker) return; - if (marker && map) { map.setCenter(marker.getPosition()); - map.setZoom(17); + + setCheckQueryParams(currentQueryParams); + return; } - } - }, [markers, map, queryParams]); + + if ( + checkQueryParams.get('pinDetail') !== + currentQueryParams.get('pinDetail') + ) { + const pinId = queryParams.get('pinDetail'); + const marker = markers.find((marker: Marker) => marker.id === pinId); + + if (marker && map) { + map.setCenter(marker.getPosition()); + map.setZoom(17); + } + setCheckQueryParams(currentQueryParams); + } + }, [markers, map, queryParams]); + }; + + return { checkQueryParams, onFocusClickedPin }; }; export default useAnimateClickedPin; diff --git a/frontend/src/hooks/useClickedCoordinate.ts b/frontend/src/hooks/useClickedCoordinate.ts index 00eafe57..f206b889 100644 --- a/frontend/src/hooks/useClickedCoordinate.ts +++ b/frontend/src/hooks/useClickedCoordinate.ts @@ -1,4 +1,5 @@ import { useContext, useEffect } from 'react'; + import { CoordinatesContext } from '../context/CoordinatesContext'; import { MarkerContext } from '../context/MarkerContext'; @@ -9,10 +10,15 @@ export default function useClickedCoordinate(map: TMap | null) { useEffect(() => { if (!map) return; + const currentZoom = map.getZoom(); if (clickedCoordinate.address) displayClickedMarker(map); // 선택된 좌표가 있으면 해당 좌표로 지도의 중심을 이동 if (clickedCoordinate.latitude && clickedCoordinate.longitude) { + if (currentZoom <= 17) { + map.setZoom(17); + } + map.panTo( new Tmapv3.LatLng( clickedCoordinate.latitude, diff --git a/frontend/src/hooks/useFocusToMarkers.ts b/frontend/src/hooks/useFocusToMarkers.ts index a0aa7704..80b2cd31 100644 --- a/frontend/src/hooks/useFocusToMarkers.ts +++ b/frontend/src/hooks/useFocusToMarkers.ts @@ -1,23 +1,31 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; const useFocusToMarker = (map: TMap | null, markers: Marker[]) => { const { Tmapv3 } = window; const bounds = useRef(new Tmapv3.LatLngBounds()); + const [markersLength, setMarkersLength] = useState(0); useEffect(() => { if (map && markers && markers.length === 1) { map.panTo(markers[0].getPosition()); } - if (map && markers && markers.length > 1) { bounds.current = new Tmapv3.LatLngBounds(); markers.forEach((marker: Marker) => { bounds.current.extend(marker.getPosition()); }); - map.fitBounds(bounds.current); + if (markersLength === 0) { + setMarkersLength(markers.length); + map.fitBounds(bounds.current); + return; + } + + if (markersLength !== markers.length) map.fitBounds(bounds.current); } + return () => { + setMarkersLength(0); + }; }, [markers]); }; - export default useFocusToMarker; diff --git a/frontend/src/hooks/useFormValues.ts b/frontend/src/hooks/useFormValues.ts index f19e59e8..c2a709ab 100644 --- a/frontend/src/hooks/useFormValues.ts +++ b/frontend/src/hooks/useFormValues.ts @@ -1,4 +1,5 @@ import { useState } from 'react'; + import { validateCurse, validatePolitically } from '../validations'; const initErrorMessages = >( diff --git a/frontend/src/hooks/useGeolocation.ts b/frontend/src/hooks/useGeolocation.ts index 10fee90a..d6930026 100644 --- a/frontend/src/hooks/useGeolocation.ts +++ b/frontend/src/hooks/useGeolocation.ts @@ -1,51 +1,115 @@ -import { useState, useEffect } from 'react'; +import { useEffect, useState } from 'react'; -interface locationType { - loaded: boolean; - currentLocation?: { latitude: number; longitude: number }; +import { USER_LOCATION_IMAGE } from '../constants/pinImage'; +import useToast from './useToast'; + +type GeoLocationState = { + coordinates: { lat: string | number; lng: string | number }; error?: { code: number; message: string }; -} +}; -const useGeolocation = () => { - const [location, setLocation] = useState({ - loaded: false, - currentLocation: { latitude: 0, longitude: 0 }, +const INIT_VALUE = ''; + +const useGeoLocation = (mapInstance: TMap | null) => { + const { Tmapv3 } = window; + const { showToast } = useToast(); + const [userMarker, setUserMarker] = useState(null); + const [isRequesting, setIsRequesting] = useState(false); + const [location, setLocation] = useState({ + coordinates: { lat: '', lng: '' }, }); - // 성공에 대한 로직 - const onSuccess = (location: { - coords: { latitude: number; longitude: number }; - }) => { + const removeUserMarker = () => { + setLocation({ + coordinates: { lat: INIT_VALUE, lng: INIT_VALUE }, + }); + + if (userMarker) { + userMarker.setMap(null); + setUserMarker(null); + } + }; + + const createUserMarkerWithZoomingMap = () => { + if (!mapInstance) return; + + const userCoordinates = new Tmapv3.LatLng( + Number(location.coordinates.lat), + Number(location.coordinates.lng), + ); + + mapInstance.setCenter(userCoordinates); + mapInstance.setZoom(17); + + const userPositionMarker = new Tmapv3.Marker({ + position: userCoordinates, + iconHTML: USER_LOCATION_IMAGE, + map: mapInstance, + }); + + setUserMarker(userPositionMarker); + }; + + const onSuccess = (position: GeolocationPosition) => { setLocation({ - loaded: true, - currentLocation: { - latitude: location.coords.latitude, - longitude: location.coords.longitude, + coordinates: { + lat: position.coords.latitude, + lng: position.coords.longitude, }, }); + + setIsRequesting(false); }; - // 에러에 대한 로직 - const onError = (error: { code: number; message: string }) => { + const onError = ( + error: Pick, + ) => { setLocation({ - loaded: true, - error, + coordinates: { lat: INIT_VALUE, lng: INIT_VALUE }, + error: { + code: error.code, + message: error.message, + }, }); + + showToast( + 'error', + '현재 위치를 사용하려면 설정에서 위치 권한을 허용해주세요.', + ); + setIsRequesting(false); }; - useEffect(() => { - // navigator 객체 안에 geolocation이 없다면 - // 위치 정보가 없는 것. + const requestUserLocation = () => { if (!('geolocation' in navigator)) { - onError({ - code: 0, - message: 'Geolocation not supported', - }); + onError({ code: 0, message: 'Geolocation not supported' }); } + + if (isRequesting) { + showToast('info', '현재 위치를 찾고 있어요.'); + return; + } + + removeUserMarker(); + + showToast('info', '현재 위치를 불러옵니다.'); + navigator.geolocation.getCurrentPosition(onSuccess, onError); + + setIsRequesting(true); + }; + + useEffect(() => { + if (location.coordinates.lat === INIT_VALUE) { + return; + } + + createUserMarkerWithZoomingMap(); }, [location]); - return location; + return { + location, + requestUserLocation, + }; }; -export default useGeolocation; +export default useGeoLocation; diff --git a/frontend/src/hooks/useKeyDown.ts b/frontend/src/hooks/useKeyDown.ts index bcbaba3d..8d3031b0 100644 --- a/frontend/src/hooks/useKeyDown.ts +++ b/frontend/src/hooks/useKeyDown.ts @@ -1,4 +1,4 @@ -import { useRef } from "react"; +import { useRef } from 'react'; const useKeyDown = () => { const elementRef = useRef(null); @@ -10,7 +10,7 @@ const useKeyDown = () => { } }; - return {elementRef, onElementKeyDown} + return { elementRef, onElementKeyDown }; }; export default useKeyDown; diff --git a/frontend/src/hooks/useMapClick.ts b/frontend/src/hooks/useMapClick.ts index 0e8dfdc8..043c5ab8 100644 --- a/frontend/src/hooks/useMapClick.ts +++ b/frontend/src/hooks/useMapClick.ts @@ -1,4 +1,5 @@ import { useContext, useEffect } from 'react'; + import { CoordinatesContext } from '../context/CoordinatesContext'; import getAddressFromServer from '../lib/getAddressFromServer'; import useToast from './useToast'; @@ -10,13 +11,13 @@ export default function useMapClick(map: TMap | null) { const clickHandler = async (evt: evt) => { try { const roadName = await getAddressFromServer( - evt.data.lngLat._lat, - evt.data.lngLat._lng, + evt.data.lngLat.lat, + evt.data.lngLat.lng, ); setClickedCoordinate({ - latitude: evt.data.lngLat._lat, - longitude: evt.data.lngLat._lng, + latitude: evt.data.lngLat.lat, + longitude: evt.data.lngLat.lng, address: roadName, }); } catch (e) { diff --git a/frontend/src/hooks/useNavigator.ts b/frontend/src/hooks/useNavigator.ts index 77f76cde..52a54151 100644 --- a/frontend/src/hooks/useNavigator.ts +++ b/frontend/src/hooks/useNavigator.ts @@ -1,12 +1,16 @@ import { useContext } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; + import { ModalContext } from '../context/ModalContext'; +import { SeeTogetherContext } from '../context/SeeTogetherContext'; const useNavigator = () => { const navigator = useNavigate(); const { openModal, closeModal } = useContext(ModalContext); const { topicId } = useParams(); + const { seeTogetherTopics } = useContext(SeeTogetherContext); + const routePage = (url: string | -1, value?: string | number | number[]) => { if (typeof url === 'string') navigator(url, { state: value }); if (url === -1) navigator(url); @@ -15,7 +19,12 @@ const useNavigator = () => { return { routingHandlers: { home: () => routePage('/'), - seeTogether: () => routePage('/see-together'), + seeTogether: () => + routePage( + `/see-together/${ + seeTogetherTopics?.length === 0 ? -1 : seeTogetherTopics?.join(',') + }`, + ), addMapOrPin: () => openModal('addMapOrPin'), favorite: () => routePage('/favorite'), profile: () => routePage('/my-page'), @@ -27,6 +36,7 @@ const useNavigator = () => { routePage('/new-pin', topicId); closeModal('addMapOrPin'); }, + search: (searchTerm: string) => routePage(`/search?${searchTerm}`), goToPopularTopics: () => routePage('see-all/popularity'), goToNearByMeTopics: () => routePage('see-all/near'), goToLatestTopics: () => routePage('see-all/latest'), diff --git a/frontend/src/hooks/useResizeMap.ts b/frontend/src/hooks/useResizeMap.ts new file mode 100644 index 00000000..57e074df --- /dev/null +++ b/frontend/src/hooks/useResizeMap.ts @@ -0,0 +1,26 @@ +import { useEffect } from 'react'; + +import useMapStore from '../store/mapInstance'; + +const getAvailableWidth = (sidebarWidth: number = 372) => + window.innerWidth - sidebarWidth; + +const getAvailableHeight = () => window.innerHeight; + +const useResizeMap = () => { + const { mapInstance } = useMapStore((state) => state); + + const resizeMap = () => { + if (!mapInstance) return; + + mapInstance.resize(getAvailableWidth(372), getAvailableHeight()); + }; + + useEffect(() => { + if (window.innerWidth > 1180) resizeMap(); + }, [getAvailableWidth(372)]); + + return { resizeMap }; +}; + +export default useResizeMap; diff --git a/frontend/src/hooks/useSetLayoutWidth.ts b/frontend/src/hooks/useSetLayoutWidth.ts index a3031e7d..7e94dad7 100644 --- a/frontend/src/hooks/useSetLayoutWidth.ts +++ b/frontend/src/hooks/useSetLayoutWidth.ts @@ -1,4 +1,5 @@ import { useContext, useEffect } from 'react'; + import { LayoutWidthContext } from '../context/LayoutWidthContext'; const useSetLayoutWidth = (layoutWidth: '100vw' | '372px') => { diff --git a/frontend/src/hooks/useSetNavbarHighlight.ts b/frontend/src/hooks/useSetNavbarHighlight.ts index 6a888765..7b95567f 100644 --- a/frontend/src/hooks/useSetNavbarHighlight.ts +++ b/frontend/src/hooks/useSetNavbarHighlight.ts @@ -1,26 +1,43 @@ import { useContext, useEffect } from 'react'; + import { NavbarHighlightKeys, NavbarHighlights, NavbarHighlightsContext, } from '../context/NavbarHighlightsContext'; -const navbarPageNames: NavbarHighlightKeys[] = [ +type NavbarPageNamesType = 'none' | NavbarHighlightKeys; + +const navbarPageNames: NavbarPageNamesType[] = [ 'home', 'seeTogether', 'addMapOrPin', 'favorite', 'profile', + 'none', ]; -const useSetNavbarHighlight = (pageName: NavbarHighlightKeys) => { +const deleteNavbarHighlights = { + home: false, + seeTogether: false, + addMapOrPin: false, + favorite: false, + profile: false, +}; + +const useSetNavbarHighlight = (pageName: NavbarPageNamesType) => { const { navbarHighlights, setNavbarHighlights } = useContext( NavbarHighlightsContext, ); useEffect(() => { + if (pageName === 'none') { + setNavbarHighlights(deleteNavbarHighlights); + return; + } + const newNavbarHighlights: NavbarHighlights = navbarPageNames.reduce( - (acc, curr) => ({ ...acc, [curr]: curr === pageName }), + (acc, cur) => ({ ...acc, [cur]: cur === pageName }), {} as NavbarHighlights, ); diff --git a/frontend/src/hooks/useTags.ts b/frontend/src/hooks/useTags.ts new file mode 100644 index 00000000..fa3e2ad6 --- /dev/null +++ b/frontend/src/hooks/useTags.ts @@ -0,0 +1,21 @@ +import { useContext } from 'react'; + +import { TagContext } from '../context/TagContext'; +import useNavigator from './useNavigator'; + +const useTags = () => { + const { tags, setTags } = useContext(TagContext); + const { routePage } = useNavigator(); + + const onClickCreateTopicWithTags = () => { + routePage('/new-topic', tags.map((tag) => tag.id).join(',')); + }; + + const onClickInitTags = () => { + setTags([]); + }; + + return { tags, setTags, onClickInitTags, onClickCreateTopicWithTags }; +}; + +export default useTags; diff --git a/frontend/src/hooks/useToast.ts b/frontend/src/hooks/useToast.ts index c02f1f3c..b21641de 100644 --- a/frontend/src/hooks/useToast.ts +++ b/frontend/src/hooks/useToast.ts @@ -1,7 +1,8 @@ import { useContext } from 'react'; -import ToastProps from '../types/Toast'; -import { ToastContext } from '../context/ToastContext'; + import { TOAST_SHOWTIME } from '../constants'; +import { ToastContext } from '../context/ToastContext'; +import ToastProps from '../types/Toast'; let timeoutID: null | number = null; diff --git a/frontend/src/hooks/useUpdateCoordinates.ts b/frontend/src/hooks/useUpdateCoordinates.ts index 6bc33d86..7a5422a4 100644 --- a/frontend/src/hooks/useUpdateCoordinates.ts +++ b/frontend/src/hooks/useUpdateCoordinates.ts @@ -1,4 +1,5 @@ import { useContext, useEffect } from 'react'; + import { CoordinatesContext } from '../context/CoordinatesContext'; import { MarkerContext } from '../context/MarkerContext'; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index aa6a5b6a..d2999a20 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,11 +1,12 @@ import ReactDOM from 'react-dom/client'; -import App from './App'; +import ReactGA from 'react-ga4'; import { ThemeProvider } from 'styled-components'; -import theme from './themes'; -import GlobalStyle from './GlobalStyle'; + +import App from './App'; import ErrorBoundary from './components/ErrorBoundary'; +import GlobalStyle from './GlobalStyle'; import NotFound from './pages/NotFound'; -import ReactGA from 'react-ga4'; +import theme from './themes'; const rootElement = document.getElementById('root'); if (!rootElement) throw new Error('Failed to find the root element'); diff --git a/frontend/src/mocks/browser.js b/frontend/src/mocks/browser.js index ec92cf08..25ce0906 100644 --- a/frontend/src/mocks/browser.js +++ b/frontend/src/mocks/browser.js @@ -1,4 +1,5 @@ import { setupWorker } from 'msw'; + import { handlers } from './handlers'; // This configures a Service Worker with the given request handlers. diff --git a/frontend/src/mocks/db/login.js b/frontend/src/mocks/db/login.js index b2b17537..2ec79ac9 100644 --- a/frontend/src/mocks/db/login.js +++ b/frontend/src/mocks/db/login.js @@ -2,7 +2,7 @@ const resLogin = { accessToken: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI5MjIzMzcyMDM2ODU0Nzc1ODA3IiwiaWF0IjoxNjkyMjcyNjg3LCJleHAiOjE2OTIyNzYyODd9.FWB1RFvk2uGInqGQDkw0SU4Lghzcggh9TSfuDEZvIUo', member: { - id: 9223372036854775807, + id: 922337203775807, nickName: '모험가03fcb0d', email: 'yshert@naver.com', imageUrl: 'https://map-befine-official.github.io/favicon.png', @@ -10,4 +10,4 @@ const resLogin = { }, }; -export default resLogin \ No newline at end of file +export default resLogin; diff --git a/frontend/src/mocks/db/resLogin.js b/frontend/src/mocks/db/resLogin.js index b2b17537..2ec79ac9 100644 --- a/frontend/src/mocks/db/resLogin.js +++ b/frontend/src/mocks/db/resLogin.js @@ -2,7 +2,7 @@ const resLogin = { accessToken: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI5MjIzMzcyMDM2ODU0Nzc1ODA3IiwiaWF0IjoxNjkyMjcyNjg3LCJleHAiOjE2OTIyNzYyODd9.FWB1RFvk2uGInqGQDkw0SU4Lghzcggh9TSfuDEZvIUo', member: { - id: 9223372036854775807, + id: 922337203775807, nickName: '모험가03fcb0d', email: 'yshert@naver.com', imageUrl: 'https://map-befine-official.github.io/favicon.png', @@ -10,4 +10,4 @@ const resLogin = { }, }; -export default resLogin \ No newline at end of file +export default resLogin; diff --git a/frontend/src/mocks/handlers.js b/frontend/src/mocks/handlers.js index be36c323..4adff8aa 100644 --- a/frontend/src/mocks/handlers.js +++ b/frontend/src/mocks/handlers.js @@ -1,13 +1,14 @@ import { rest } from 'msw'; -import topics from './db/topics'; -import newestTopics from './db/newestTopics'; + +import atlas from './db/atlas'; import bestTopics from './db/bestTopics'; -import detailTopic from './db/detailTopic'; -import tempPins from './db/tempPins'; -import resLogin from './db/resLogin'; import bookmarks from './db/bookmarks'; +import detailTopic from './db/detailTopic'; import myTopics from './db/myTopics'; -import atlas from './db/atlas'; +import newestTopics from './db/newestTopics'; +import resLogin from './db/resLogin'; +import tempPins from './db/tempPins'; +import topics from './db/topics'; export const handlers = [ // 포스트 목록 @@ -180,7 +181,7 @@ export const handlers = [ description, creator: '패트릭', isInAtlas: false, - pins: pins, + pins, isBookmarked: false, bookmarkCount: 5, pinCount: 0, @@ -207,8 +208,8 @@ export const handlers = [ name, description, address, - latitude: latitude, - longitude: longitude, + latitude, + longitude, legalDongCode: '', images: [], }; diff --git a/frontend/src/pages/AskLogin.tsx b/frontend/src/pages/AskLogin.tsx index 1416e6ce..559a18b5 100644 --- a/frontend/src/pages/AskLogin.tsx +++ b/frontend/src/pages/AskLogin.tsx @@ -1,14 +1,17 @@ import { styled } from 'styled-components'; + import LoginErrorIcon from '../assets/LoginErrorIcon.svg'; import Button from '../components/common/Button'; import Flex from '../components/common/Flex'; import Space from '../components/common/Space'; import Text from '../components/common/Text'; -import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; import { DEFAULT_PROD_URL, FULLSCREEN } from '../constants'; +import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; +import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; -const AskLogin = () => { +function AskLogin() { const { width } = useSetLayoutWidth(FULLSCREEN); + useSetNavbarHighlight('none'); const loginButtonClick = async () => { window.location.href = `${DEFAULT_PROD_URL}/oauth/kakao`; @@ -40,7 +43,7 @@ const AskLogin = () => { ); -}; +} const NotFoundButton = styled(Button)` width: 270px; @@ -49,7 +52,7 @@ const NotFoundButton = styled(Button)` background-color: rgb(255, 220, 0); color: ${({ theme }) => theme.color.black}; - font-weight: ${({ theme }) => theme.fontWeight['bold']}; + font-weight: ${({ theme }) => theme.fontWeight.bold}; border: 1px solid ${({ theme }) => theme.color.white}; `; diff --git a/frontend/src/pages/Bookmark.tsx b/frontend/src/pages/Bookmark.tsx index f84d6a42..1e84cdac 100644 --- a/frontend/src/pages/Bookmark.tsx +++ b/frontend/src/pages/Bookmark.tsx @@ -1,20 +1,21 @@ +import { lazy, Suspense } from 'react'; import { styled } from 'styled-components'; + +import FavoriteNotFilledSVG from '../assets/favoriteBtn_notFilled.svg'; import Box from '../components/common/Box'; -import Text from '../components/common/Text'; -import { FULLSCREEN } from '../constants'; -import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; -import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; import Flex from '../components/common/Flex'; import Space from '../components/common/Space'; -import { Suspense, lazy } from 'react'; +import MediaSpace from '../components/common/Space/MediaSpace'; +import MediaText from '../components/common/Text/MediaText'; import TopicCardContainerSkeleton from '../components/Skeletons/TopicListSkeleton'; +import { FULLSCREEN } from '../constants'; import useNavigator from '../hooks/useNavigator'; -import FavoriteNotFilledSVG from '../assets/favoriteBtn_notFilled.svg'; -import { setFullScreenResponsive } from '../constants/responsive'; +import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; +import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; const TopicCardList = lazy(() => import('../components/TopicCardList')); -const Bookmark = () => { +function Bookmark() { const { routePage } = useNavigator(); useSetLayoutWidth(FULLSCREEN); useSetNavbarHighlight('favorite'); @@ -28,27 +29,27 @@ const Bookmark = () => { - 즐겨찾기 - + - 즐겨찾기한 지도들을 한 눈에 보세요. - + - + }> { + + ); -}; +} const Wrapper = styled.article` - width: 1036px; + width: 1140px; margin: 0 auto; + position: relative; - ${setFullScreenResponsive()} + @media (max-width: 1180px) { + width: 100%; + } `; export default Bookmark; diff --git a/frontend/src/pages/Home.stories.ts b/frontend/src/pages/Home.stories.ts index a6a8010d..65bc6cc3 100644 --- a/frontend/src/pages/Home.stories.ts +++ b/frontend/src/pages/Home.stories.ts @@ -1,4 +1,5 @@ -import { StoryObj, Meta } from '@storybook/react'; +import { Meta, StoryObj } from '@storybook/react'; + import Home from './Home'; const meta = { diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 5ca139b9..4aa859ea 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,29 +1,44 @@ +import { lazy, Suspense, useContext, useEffect } from 'react'; +import { styled } from 'styled-components'; + +import Banner from '../components/Banner'; import Space from '../components/common/Space'; +import MediaSpace from '../components/common/Space/MediaSpace'; +import SearchBar from '../components/SearchBar/SearchBar'; +import TopicCardContainerSkeleton from '../components/Skeletons/TopicListSkeleton'; +import { FULLSCREEN } from '../constants'; +import { MarkerContext } from '../context/MarkerContext'; +import { SeeTogetherContext } from '../context/SeeTogetherContext'; import useNavigator from '../hooks/useNavigator'; -import { css, styled } from 'styled-components'; import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; -import { FULLSCREEN } from '../constants'; import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; -import { Suspense, lazy, useContext, useEffect } from 'react'; -import { MarkerContext } from '../context/MarkerContext'; -import TopicCardContainerSkeleton from '../components/Skeletons/TopicListSkeleton'; -import { setFullScreenResponsive } from '../constants/responsive'; +import useToast from '../hooks/useToast'; const TopicListContainer = lazy( () => import('../components/TopicCardContainer'), ); -const Home = () => { +function Home() { + const accessToken = localStorage.getItem('userToken'); const { routingHandlers } = useNavigator(); const { goToPopularTopics, goToLatestTopics, goToNearByMeTopics } = routingHandlers; - + const { seeTogetherTopics, setSeeTogetherTopics } = + useContext(SeeTogetherContext); const { markers, removeMarkers, removeInfowindows } = useContext(MarkerContext); + const { showToast } = useToast(); useSetLayoutWidth(FULLSCREEN); useSetNavbarHighlight('home'); + useEffect(() => { + if (accessToken === null && seeTogetherTopics?.length !== 0) { + setSeeTogetherTopics([]); + showToast('info', '로그인을 하면 모아보기가 유지돼요.'); + } + }, []); + useEffect(() => { if (markers && markers.length > 0) { removeMarkers(); @@ -33,7 +48,12 @@ const Home = () => { return ( - + + + + + + }> { /> - + }> { /> - + }> { /> - + ); -}; +} const Wrapper = styled.article` - width: 1036px; + width: 1140px; margin: 0 auto; position: relative; - ${setFullScreenResponsive()} + @media (max-width: 1180px) { + width: 100%; + } `; export default Home; diff --git a/frontend/src/pages/KakaoRedirect.tsx b/frontend/src/pages/KakaoRedirect.tsx index 985f33b8..69c68e75 100644 --- a/frontend/src/pages/KakaoRedirect.tsx +++ b/frontend/src/pages/KakaoRedirect.tsx @@ -1,9 +1,10 @@ import { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import { keyframes, styled } from 'styled-components'; -import useNavigator from '../hooks/useNavigator'; -import { DEFAULT_PROD_URL } from '../constants'; + import { getLoginApi } from '../apis/getLoginApi'; +import { DEFAULT_PROD_URL } from '../constants'; +import useNavigator from '../hooks/useNavigator'; // const API_URL = // process.env.NODE_ENV === 'production' @@ -19,11 +20,11 @@ export const handleOAuthKakao = async (code: string) => { localStorage.setItem('user', JSON.stringify(data.member)); location.reload(); } catch (error) { - window.alert('로그인에 실패하였습니다. 이메일 수집을 동의해주세요.'); + window.alert('로그인에 실패하였습니다. 로그아웃 후 다시 진행해주세요.'); } }; -const KakaoRedirect = () => { +function KakaoRedirect() { const { routePage } = useNavigator(); const routerLocation = useLocation(); @@ -46,7 +47,7 @@ const KakaoRedirect = () => { ); -}; +} export default KakaoRedirect; diff --git a/frontend/src/pages/NewPin.tsx b/frontend/src/pages/NewPin.tsx index ff2b5c3c..d1e30358 100644 --- a/frontend/src/pages/NewPin.tsx +++ b/frontend/src/pages/NewPin.tsx @@ -1,37 +1,39 @@ -import Input from '../components/common/Input'; -import Text from '../components/common/Text'; -import Flex from '../components/common/Flex'; -import Space from '../components/common/Space'; -import Button from '../components/common/Button'; -import { postApi } from '../apis/postApi'; +/* eslint-disable no-nested-ternary */ import { FormEvent, useContext, useEffect, useState } from 'react'; -import { getApi } from '../apis/getApi'; -import { TopicCardProps } from '../types/Topic'; -import useNavigator from '../hooks/useNavigator'; -import { NewPinFormProps } from '../types/FormValues'; -import useFormValues from '../hooks/useFormValues'; -import { MarkerContext } from '../context/MarkerContext'; -import { CoordinatesContext } from '../context/CoordinatesContext'; import { useLocation } from 'react-router-dom'; -import useToast from '../hooks/useToast'; +import { styled } from 'styled-components'; + +import { getApi } from '../apis/getApi'; +import { postApi } from '../apis/postApi'; +import Button from '../components/common/Button'; +import Flex from '../components/common/Flex'; +import Autocomplete from '../components/common/Input/Autocomplete'; +import Space from '../components/common/Space'; +import Text from '../components/common/Text'; import InputContainer from '../components/InputContainer'; -import { hasErrorMessage, hasNullValue } from '../validations'; -import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; -import { LAYOUT_PADDING, SIDEBAR } from '../constants'; -import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; -import { ModalContext } from '../context/ModalContext'; import Modal from '../components/Modal'; -import { styled } from 'styled-components'; import ModalMyTopicList from '../components/ModalMyTopicList'; -import { getMapApi } from '../apis/getMapApi'; +import { LAYOUT_PADDING, SIDEBAR } from '../constants'; +import { CoordinatesContext } from '../context/CoordinatesContext'; +import { MarkerContext } from '../context/MarkerContext'; +import { ModalContext } from '../context/ModalContext'; import useCompressImage from '../hooks/useCompressImage'; +import useFormValues from '../hooks/useFormValues'; +import useNavigator from '../hooks/useNavigator'; +import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; +import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; +import useToast from '../hooks/useToast'; +import { NewPinFormProps } from '../types/FormValues'; +import { Poi } from '../types/Poi'; +import { TopicCardProps } from '../types/Topic'; +import { hasErrorMessage, hasNullValue } from '../validations'; type NewPinFormValueType = Pick< NewPinFormProps, 'name' | 'address' | 'description' >; -const NewPin = () => { +function NewPin() { const { state: topicId } = useLocation(); const { navbarHighlights: _ } = useSetNavbarHighlight('addMapOrPin'); const [topic, setTopic] = useState(null); @@ -50,7 +52,7 @@ const NewPin = () => { const { routePage } = useNavigator(); const { showToast } = useToast(); const { width } = useSetLayoutWidth(SIDEBAR); - const { openModal, closeModal } = useContext(ModalContext); + const { openModal } = useContext(ModalContext); const { compressImageList } = useCompressImage(); const [formImages, setFormImages] = useState([]); @@ -61,12 +63,12 @@ const NewPin = () => { const postToServer = async () => { let postTopicId = topic?.id; - let postName = formValues.name; + const postName = formValues.name; const formData = new FormData(); if (!topic) { - //토픽이 없으면 selectedTopic을 통해 토픽을 생성한다. + // 토픽이 없으면 selectedTopic을 통해 토픽을 생성한다. postTopicId = selectedTopic?.topicId; } @@ -130,12 +132,10 @@ const NewPin = () => { return; } let postTopicId = topic?.id; - let postName = formValues.name; if (!topic) { - //토픽이 없으면 selectedTopic을 통해 토픽을 생성한다. + // 토픽이 없으면 selectedTopic을 통해 토픽을 생성한다. postTopicId = selectedTopic?.topicId; - postName = selectedTopic?.topicName; } if (postTopicId) routePage(`/topics/${postTopicId}`, [postTopicId]); @@ -147,39 +147,6 @@ const NewPin = () => { } }; - const onClickAddressInput = ( - e: - | React.MouseEvent - | React.KeyboardEvent, - ) => { - if (!(e.type === 'click') && e.currentTarget.value) return; - - var width = 500; //팝업의 너비 - var height = 600; //팝업의 높이 - new window.daum.Postcode({ - width: width, //생성자에 크기 값을 명시적으로 지정해야 합니다. - height: height, - onComplete: async function (data: any) { - const addr = data.roadAddress; // 주소 변수 - - //data를 통해 받아온 값을 Tmap api를 통해 위도와 경도를 구한다. - const { ConvertAdd } = await getMapApi( - `https://apis.openapi.sk.com/tmap/geo/convertAddress?version=1&format=json&callback=result&searchTypCd=NtoO&appKey=P2MX6F1aaf428AbAyahIl9L8GsIlES04aXS9hgxo&coordType=WGS84GEO&reqAdd=${addr}`, - ); - const lat = ConvertAdd.oldLat; - const lng = ConvertAdd.oldLon; - - setClickedCoordinate({ - latitude: lat, - longitude: lng, - address: addr, - }); - }, - }).open({ - popupKey: 'postPopUp', - }); - }; - const onPinImageChange = async ( event: React.ChangeEvent, ) => { @@ -196,7 +163,7 @@ const NewPin = () => { const compressedImageList = await compressImageList(imageLists); - for (let i = 0; i < imageLists.length; i++) { + for (let i = 0; i < imageLists.length; i += 1) { const currentImageUrl = URL.createObjectURL(compressedImageList[i]); imageUrlLists.push(currentImageUrl); } @@ -214,6 +181,17 @@ const NewPin = () => { setShowedImages(imageUrlLists); }; + const onSuggestionSelected = (suggestion: Poi) => { + const { noorLat, noorLon } = suggestion; + const address = `${suggestion.upperAddrName} ${suggestion.middleAddrName} ${suggestion.roadName}[${suggestion.name}]`; + + setClickedCoordinate({ + latitude: Number(noorLat), + longitude: Number(noorLon), + address, + }); + }; + useEffect(() => { const getTopicId = async () => { if (topicId && topicId.split(',').length === 1) { @@ -293,9 +271,9 @@ const NewPin = () => { - {showedImages.map((image, id) => ( -
    - + {showedImages.map((image, idx) => ( +
    +
    ))} @@ -306,7 +284,7 @@ const NewPin = () => { { - @@ -343,7 +316,7 @@ const NewPin = () => { { ); -}; +} const Wrapper = styled(Flex)` margin: 0 auto; diff --git a/frontend/src/pages/NewTopic.stories.ts b/frontend/src/pages/NewTopic.stories.ts index 2972086b..4039d65a 100644 --- a/frontend/src/pages/NewTopic.stories.ts +++ b/frontend/src/pages/NewTopic.stories.ts @@ -1,4 +1,5 @@ -import { StoryObj, Meta } from '@storybook/react'; +import { Meta, StoryObj } from '@storybook/react'; + import NewTopic from './NewTopic'; const meta = { diff --git a/frontend/src/pages/NewTopic.tsx b/frontend/src/pages/NewTopic.tsx index 11de1431..7d1a75b2 100644 --- a/frontend/src/pages/NewTopic.tsx +++ b/frontend/src/pages/NewTopic.tsx @@ -1,28 +1,29 @@ import { useContext, useEffect, useState } from 'react'; -import Text from '../components/common/Text'; +import { useLocation } from 'react-router-dom'; +import styled from 'styled-components'; + +import usePost from '../apiHooks/usePost'; +import AuthorityRadioContainer from '../components/AuthorityRadioContainer'; +import Button from '../components/common/Button'; import Flex from '../components/common/Flex'; import Space from '../components/common/Space'; -import Button from '../components/common/Button'; -import useNavigator from '../hooks/useNavigator'; -import { NewTopicFormProps } from '../types/FormValues'; -import useFormValues from '../hooks/useFormValues'; -import { useLocation } from 'react-router-dom'; -import useToast from '../hooks/useToast'; +import Text from '../components/common/Text'; import InputContainer from '../components/InputContainer'; -import { hasErrorMessage, hasNullValue } from '../validations'; -import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; import { LAYOUT_PADDING, SIDEBAR } from '../constants'; -import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; +import { MarkerContext } from '../context/MarkerContext'; import { TagContext } from '../context/TagContext'; -import usePost from '../apiHooks/usePost'; -import AuthorityRadioContainer from '../components/AuthorityRadioContainer'; -import styled from 'styled-components'; import useCompressImage from '../hooks/useCompressImage'; -import { MarkerContext } from '../context/MarkerContext'; +import useFormValues from '../hooks/useFormValues'; +import useNavigator from '../hooks/useNavigator'; +import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; +import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; +import useToast from '../hooks/useToast'; +import { NewTopicFormProps } from '../types/FormValues'; +import { hasErrorMessage, hasNullValue } from '../validations'; type NewTopicFormValuesType = Omit; -const NewTopic = () => { +function NewTopic() { const { routePage } = useNavigator(); const { state: pulledPinIds } = useLocation(); const { showToast } = useToast(); @@ -126,6 +127,7 @@ const NewTopic = () => { event: React.ChangeEvent, ) => { const file = event.target.files && event.target.files[0]; + const currentImage = new Image(); if (!file) { showToast( 'error', @@ -135,9 +137,20 @@ const NewTopic = () => { } const compressedFile = await compressImage(file); - - setFormImage(compressedFile); - setShowImage(URL.createObjectURL(file)); + currentImage.src = URL.createObjectURL(compressedFile); + + currentImage.onload = () => { + if (currentImage.width < 300) { + showToast( + 'error', + '이미지의 크기가 너무 작습니다. 다른 이미지를 선택해 주세요.', + ); + return; + } + + setFormImage(compressedFile); + setShowImage(URL.createObjectURL(file)); + }; }; useEffect(() => { @@ -170,8 +183,7 @@ const NewTopic = () => { {showImage && ( <> - {' '} - {' '} + {' '} )} @@ -189,7 +201,7 @@ const NewTopic = () => { { { ); -}; +} const Wrapper = styled(Flex)` margin: 0 auto; diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx index 3ce270a4..72096ed6 100644 --- a/frontend/src/pages/NotFound.tsx +++ b/frontend/src/pages/NotFound.tsx @@ -1,14 +1,15 @@ import { styled } from 'styled-components'; + import NotFoundIcon from '../assets/NotFoundIcon.svg'; -import useNavigator from '../hooks/useNavigator'; import Button from '../components/common/Button'; import Flex from '../components/common/Flex'; import Space from '../components/common/Space'; import Text from '../components/common/Text'; -import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; import { FULLSCREEN } from '../constants'; +import useNavigator from '../hooks/useNavigator'; +import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; -const NotFound = () => { +function NotFound() { const { routePage } = useNavigator(); useSetLayoutWidth(FULLSCREEN); @@ -39,7 +40,7 @@ const NotFound = () => { ); -}; +} const NotFoundContainer = styled(Flex)` flex-direction: row; @@ -49,7 +50,7 @@ const NotFoundContainer = styled(Flex)` `; const NotFoundButton = styled(Button)` - font-weight: ${({ theme }) => theme.fontWeight['bold']}; + font-weight: ${({ theme }) => theme.fontWeight.bold}; &:hover { color: ${({ theme }) => theme.color.white}; diff --git a/frontend/src/pages/PinDetail.stories.ts b/frontend/src/pages/PinDetail.stories.ts index 29ce16b0..4a02df74 100644 --- a/frontend/src/pages/PinDetail.stories.ts +++ b/frontend/src/pages/PinDetail.stories.ts @@ -1,4 +1,5 @@ -import { StoryObj, Meta } from '@storybook/react'; +import { Meta, StoryObj } from '@storybook/react'; + import PinDetail from './PinDetail'; const meta = { diff --git a/frontend/src/pages/PinDetail.tsx b/frontend/src/pages/PinDetail.tsx index 1706b7de..0e909fa3 100644 --- a/frontend/src/pages/PinDetail.tsx +++ b/frontend/src/pages/PinDetail.tsx @@ -1,23 +1,29 @@ -import Flex from '../components/common/Flex'; -import Space from '../components/common/Space'; -import Text from '../components/common/Text'; import { useContext, useEffect, useState } from 'react'; -import { PinProps } from '../types/Pin'; -import { getApi } from '../apis/getApi'; import { useSearchParams } from 'react-router-dom'; +import { styled } from 'styled-components'; + +import { getApi } from '../apis/getApi'; +import { postApi } from '../apis/postApi'; +import UpdateBtnSVG from '../assets/updateBtn.svg'; import Box from '../components/common/Box'; -import UpdatedPinDetail from './UpdatedPinDetail'; -import useFormValues from '../hooks/useFormValues'; -import { ModifyPinFormProps } from '../types/FormValues'; -import useToast from '../hooks/useToast'; import Button from '../components/common/Button'; +import Flex from '../components/common/Flex'; +import SingleComment, { + ProfileImage, +} from '../components/common/Input/SingleComment'; +import Space from '../components/common/Space'; +import Text from '../components/common/Text'; import Modal from '../components/Modal'; -import { styled } from 'styled-components'; -import { ModalContext } from '../context/ModalContext'; import AddToMyTopicList from '../components/ModalMyTopicList/addToMyTopicList'; -import { postApi } from '../apis/postApi'; import PinImageContainer from '../components/PinImageContainer'; +import { ModalContext } from '../context/ModalContext'; import useCompressImage from '../hooks/useCompressImage'; +import useFormValues from '../hooks/useFormValues'; +import useToast from '../hooks/useToast'; +import theme from '../themes'; +import { ModifyPinFormProps } from '../types/FormValues'; +import { PinProps } from '../types/Pin'; +import UpdatedPinDetail from './UpdatedPinDetail'; interface PinDetailProps { width: '372px' | '100vw'; @@ -26,16 +32,20 @@ interface PinDetailProps { setIsEditPinDetail: React.Dispatch>; } -const userToken = localStorage.getItem('userToken'); +const userToken = localStorage?.getItem('userToken'); +const localStorageUser = localStorage?.getItem('user'); +const user = JSON.parse(localStorageUser || '{}'); -const PinDetail = ({ +function PinDetail({ width, pinId, isEditPinDetail, setIsEditPinDetail, -}: PinDetailProps) => { +}: PinDetailProps) { const [searchParams, setSearchParams] = useSearchParams(); const [pin, setPin] = useState(null); + const [commentList, setCommentList] = useState([]); // 댓글 리스트 + const [newComment, setNewComment] = useState(''); const { showToast } = useToast(); const { formValues, @@ -70,6 +80,7 @@ const PinDetail = ({ useEffect(() => { getPinData(); + setCurrentPageCommentList(pinId); }, [pinId, searchParams]); const onClickEditPin = () => { @@ -117,6 +128,40 @@ const PinDetail = ({ getPinData(); }; + // 댓글 구현 부분 + const setCurrentPageCommentList = async (pinId: number) => { + const data = await getApi(`/pins/${pinId}/comments`); + setCommentList(data); + return data; + }; + + useEffect(() => { + setCurrentPageCommentList(pinId); + }, []); + + const onClickCommentBtn = async (e: React.MouseEvent) => { + e.stopPropagation(); + + try { + // 댓글 추가 + // comment 값이랑 추가 정보 body에 담아서 보내기 + await postApi( + `/pins/comments`, + { + pinId, + content: newComment, + parentPinCommentId: null, + }, + 'application/json', + ); + + setCurrentPageCommentList(pinId); + setNewComment(''); + showToast('info', '댓글이 추가되었습니다.'); + } catch { + showToast('error', '댓글을 다시 작성해주세요'); + } + }; if (!pin) return <>; if (isEditPinDetail) @@ -142,9 +187,7 @@ const PinDetail = ({ {pin.name} - - {pin.creator} @@ -152,14 +195,7 @@ const PinDetail = ({ {pin.canUpdate ? ( - - 수정하기 - + ) : ( @@ -169,21 +205,18 @@ const PinDetail = ({ - - - 파일업로드 + {userToken && ( + 파일업로드 + )} - - - + - 어디에 있나요? @@ -203,8 +236,7 @@ const PinDetail = ({ {pin.description} - - + @@ -218,6 +250,49 @@ const PinDetail = ({ + {/* Comment Section */} + + + 어떻게 생각하나요?{' '} + + + {userToken && ( +
    + +
    + ) => + setNewComment(e.target.value) + } + placeholder="댓글 추가" + /> + + 등록 + +
    +
    + )} + {commentList?.length > 0 && + commentList.map( + (comment) => + !comment.parentPinCommentId ? ( + + ) : null, // <-- comment.parentPinCommentId가 존재하는 경우 null 반환 + )} + {/* comment section END */} + + ); -}; +} const Wrapper = styled.section<{ $layoutWidth: '372px' | '100vw'; @@ -307,7 +382,7 @@ const ButtonsWrapper = styled.div` align-items: center; width: 332px; height: 48px; - margin: 0 auto; + margin: 24px auto 0; `; const ImageInputLabel = styled.label` @@ -329,4 +404,28 @@ const ImageInputButton = styled.input` display: none; `; +export const CustomInput = styled.input` + width: 100%; + border-top: none; + border-left: none; + border-right: none; + border-bottom: 2px solid ${theme.color.lightGray}; + font-size: 16px; + + &:focus { + outline: none; + border-bottom: 2px solid ${theme.color.black}; + } +`; + +export const ConfirmCommentButton = styled(Button)` + font-size: ${({ theme }) => theme.fontSize.extraSmall}; + font-weight: ${({ theme }) => theme.fontWeight.bold}; + + box-shadow: 8px 8px 8px 0px rgba(69, 69, 69, 0.15); + + margin-top: 12px; + float: right; + font-size: 12px; +`; export default PinDetail; diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index abafac26..33345e3d 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -1,20 +1,21 @@ +import { lazy, Suspense } from 'react'; import { styled } from 'styled-components'; + import Box from '../components/common/Box'; import Flex from '../components/common/Flex'; import Space from '../components/common/Space'; +import MediaSpace from '../components/common/Space/MediaSpace'; +import MediaText from '../components/common/Text/MediaText'; import MyInfo from '../components/MyInfo'; -import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; -import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; -import { FULLSCREEN } from '../constants'; import TopicCardContainerSkeleton from '../components/Skeletons/TopicListSkeleton'; -import { Suspense, lazy } from 'react'; -import Text from '../components/common/Text'; +import { FULLSCREEN } from '../constants'; import useNavigator from '../hooks/useNavigator'; -import { setFullScreenResponsive } from '../constants/responsive'; +import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; +import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; const TopicCardList = lazy(() => import('../components/TopicCardList')); -const Profile = () => { +function Profile() { const { routePage } = useNavigator(); useSetLayoutWidth(FULLSCREEN); useSetNavbarHighlight('profile'); @@ -33,27 +34,27 @@ const Profile = () => { - 나의 지도 - + - 내가 만든 지도를 확인해보세요. - + - + }> { routePage={goToNewTopic} /> + + ); -}; +} const Wrapper = styled(Box)` - width: 1036px; + width: 1140px; margin: 0 auto; + position: relative; - ${setFullScreenResponsive()} + @media (max-width: 1180px) { + width: 100%; + } `; const MyInfoWrapper = styled(Flex)` diff --git a/frontend/src/pages/RootPage.tsx b/frontend/src/pages/RootPage.tsx index a0934c53..8bcaec99 100644 --- a/frontend/src/pages/RootPage.tsx +++ b/frontend/src/pages/RootPage.tsx @@ -1,10 +1,11 @@ import { Outlet } from 'react-router-dom'; + import Layout from '../components/Layout'; import LayoutWidthProvider from '../context/LayoutWidthContext'; import NavbarHighlightsProvider from '../context/NavbarHighlightsContext'; import RouteChangeTracker from '../utils/RouteChangeTracker'; -const RootPage = () => { +function RootPage() { RouteChangeTracker(); return ( <> @@ -17,6 +18,6 @@ const RootPage = () => { ); -}; +} export default RootPage; diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx new file mode 100644 index 00000000..ff5a00fb --- /dev/null +++ b/frontend/src/pages/Search.tsx @@ -0,0 +1,146 @@ +import { Fragment, useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import styled from 'styled-components'; + +import useGet from '../apiHooks/useGet'; +import Box from '../components/common/Box'; +import Flex from '../components/common/Flex'; +import Grid from '../components/common/Grid'; +import Space from '../components/common/Space'; +import MediaText from '../components/common/Text/MediaText'; +import SearchBar from '../components/SearchBar/SearchBar'; +import TopicCard from '../components/TopicCard'; +import { TopicCardProps } from '../types/Topic'; + +function Search() { + const { fetchGet } = useGet(); + + const [originalTopics, setOriginalTopics] = useState( + null, + ); + const [displayedTopics, setDisplayedTopics] = useState< + TopicCardProps[] | null + >(null); + const searchQuery = decodeURIComponent(useLocation().search.substring(1)); + + const getTopicsFromServer = async () => { + fetchGet( + '/topics', + '지도를 가져오는데 실패했습니다.', + (response) => { + setOriginalTopics(response); + const searchResult = response.filter((topic) => + topic.name.includes(searchQuery), + ); + setDisplayedTopics(searchResult); + }, + ); + }; + + useEffect(() => { + getTopicsFromServer(); + }, []); + + useEffect(() => { + if (originalTopics) { + const searchResult = originalTopics.filter((topic) => + topic.name.includes(searchQuery), + ); + setDisplayedTopics(searchResult); + } + }, [searchQuery]); + + return ( + + + + + + + + 찾았을 지도? + + + + {`${searchQuery} 검색 결과입니다.`} + + + + + + {displayedTopics?.length === 0 ? ( + // 검색 결과가 없을 때의 UI + + + + + {`'${searchQuery}'에 대한 검색 결과가 없습니다.`} + + + + + + ) : ( + + {displayedTopics?.map((topic) => ( + + + + ))} + + )} + + + ); +} + +export default Search; + +const Wrapper = styled.article` + width: 1140px; + margin: 0 auto; + position: relative; + + @media (max-width: 1180px) { + width: 100%; + } +`; + +const EmptyWrapper = styled.section` + height: 240px; + display: flex; + flex-direction: column; + align-items: center; +`; diff --git a/frontend/src/pages/SeeAllLatestTopics.tsx b/frontend/src/pages/SeeAllLatestTopics.tsx index e949f1a4..93cff787 100644 --- a/frontend/src/pages/SeeAllLatestTopics.tsx +++ b/frontend/src/pages/SeeAllLatestTopics.tsx @@ -1,18 +1,19 @@ +import { lazy, Suspense } from 'react'; import { styled } from 'styled-components'; + +import Box from '../components/common/Box'; import Space from '../components/common/Space'; -import Text from '../components/common/Text'; +import MediaSpace from '../components/common/Space/MediaSpace'; +import MediaText from '../components/common/Text/MediaText'; +import TopicCardContainerSkeleton from '../components/Skeletons/TopicListSkeleton'; import { FULLSCREEN } from '../constants'; +import useNavigator from '../hooks/useNavigator'; import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; -import Box from '../components/common/Box'; -import { Suspense, lazy } from 'react'; -import TopicCardContainerSkeleton from '../components/Skeletons/TopicListSkeleton'; -import useNavigator from '../hooks/useNavigator'; -import { setFullScreenResponsive } from '../constants/responsive'; const TopicCardList = lazy(() => import('../components/TopicCardList')); -const SeeAllLatestTopics = () => { +function SeeAllLatestTopics() { const { routePage } = useNavigator(); useSetLayoutWidth(FULLSCREEN); useSetNavbarHighlight('home'); @@ -24,11 +25,11 @@ const SeeAllLatestTopics = () => { return ( - + 새로울 지도? - + - + }> { routePage={goToHome} /> + + ); -}; +} const Wrapper = styled(Box)` - width: 1036px; + width: 1140px; margin: 0 auto; + position: relative; - ${setFullScreenResponsive()} + @media (max-width: 1180px) { + width: 100%; + } `; export default SeeAllLatestTopics; diff --git a/frontend/src/pages/SeeAllNearTopics.tsx b/frontend/src/pages/SeeAllNearTopics.tsx index 15cd3035..022d2a41 100644 --- a/frontend/src/pages/SeeAllNearTopics.tsx +++ b/frontend/src/pages/SeeAllNearTopics.tsx @@ -1,18 +1,19 @@ +import { lazy, Suspense } from 'react'; import { styled } from 'styled-components'; -import { FULLSCREEN } from '../constants'; -import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; -import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; + import Box from '../components/common/Box'; import Space from '../components/common/Space'; -import Text from '../components/common/Text'; +import MediaSpace from '../components/common/Space/MediaSpace'; +import MediaText from '../components/common/Text/MediaText'; import TopicCardContainerSkeleton from '../components/Skeletons/TopicListSkeleton'; -import { Suspense, lazy } from 'react'; +import { FULLSCREEN } from '../constants'; import useNavigator from '../hooks/useNavigator'; -import { setFullScreenResponsive } from '../constants/responsive'; +import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; +import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; const TopicCardList = lazy(() => import('../components/TopicCardList')); -const SeeAllNearTopics = () => { +function SeeAllNearTopics() { const { routePage } = useNavigator(); useSetLayoutWidth(FULLSCREEN); useSetNavbarHighlight('home'); @@ -24,11 +25,11 @@ const SeeAllNearTopics = () => { return ( - + 내 주변일 지도? - + - + }> { routePage={goToHome} /> + + ); -}; +} const Wrapper = styled(Box)` - width: 1036px; + width: 1140px; margin: 0 auto; + position: relative; - ${setFullScreenResponsive()} + @media (max-width: 1180px) { + width: 100%; + } `; export default SeeAllNearTopics; diff --git a/frontend/src/pages/SeeAllPopularTopics.tsx b/frontend/src/pages/SeeAllPopularTopics.tsx index d40d25c0..275a402b 100644 --- a/frontend/src/pages/SeeAllPopularTopics.tsx +++ b/frontend/src/pages/SeeAllPopularTopics.tsx @@ -1,18 +1,19 @@ -import Text from '../components/common/Text'; -import Space from '../components/common/Space'; +import { lazy, Suspense } from 'react'; import { styled } from 'styled-components'; + import Box from '../components/common/Box'; -import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; -import { FULLSCREEN } from '../constants'; -import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; -import { Suspense, lazy } from 'react'; +import Space from '../components/common/Space'; +import MediaSpace from '../components/common/Space/MediaSpace'; +import MediaText from '../components/common/Text/MediaText'; import TopicCardContainerSkeleton from '../components/Skeletons/TopicListSkeleton'; +import { FULLSCREEN } from '../constants'; import useNavigator from '../hooks/useNavigator'; -import { setFullScreenResponsive } from '../constants/responsive'; +import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; +import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; const TopicCardList = lazy(() => import('../components/TopicCardList')); -const SeeAllTopics = () => { +function SeeAllTopics() { const { routePage } = useNavigator(); useSetLayoutWidth(FULLSCREEN); useSetNavbarHighlight('home'); @@ -24,11 +25,11 @@ const SeeAllTopics = () => { return ( - + 인기 급상승할 지도? - + - + }> { routePage={goToHome} /> + + ); -}; +} const Wrapper = styled(Box)` - width: 1036px; + width: 1140px; margin: 0 auto; + position: relative; - ${setFullScreenResponsive()} + @media (max-width: 1180px) { + width: 100%; + } `; export default SeeAllTopics; diff --git a/frontend/src/pages/SeeTogether.tsx b/frontend/src/pages/SeeTogether.tsx new file mode 100644 index 00000000..c13cef55 --- /dev/null +++ b/frontend/src/pages/SeeTogether.tsx @@ -0,0 +1,263 @@ +import { Fragment, Suspense, useContext, useEffect, useState } from 'react'; +import { useLocation, useParams, useSearchParams } from 'react-router-dom'; +import { styled } from 'styled-components'; + +import { getApi } from '../apis/getApi'; +import SeeTogetherNotFilledSVG from '../assets/seeTogetherBtn_notFilled.svg'; +import Button from '../components/common/Button'; +import Flex from '../components/common/Flex'; +import Space from '../components/common/Space'; +import Text from '../components/common/Text'; +import PinsOfTopic from '../components/PinsOfTopic'; +import PullPin from '../components/PullPin'; +import PinsOfTopicSkeleton from '../components/Skeletons/PinsOfTopicSkeleton'; +import { LAYOUT_PADDING, SIDEBAR } from '../constants'; +import { CoordinatesContext } from '../context/CoordinatesContext'; +import { MarkerContext } from '../context/MarkerContext'; +import { SeeTogetherContext } from '../context/SeeTogetherContext'; +import useNavigator from '../hooks/useNavigator'; +import useResizeMap from '../hooks/useResizeMap'; +import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; +import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; +import useTags from '../hooks/useTags'; +import { PinProps } from '../types/Pin'; +import { TopicDetailProps } from '../types/Topic'; +import PinDetail from './PinDetail'; + +function SeeTogether() { + const accessToken = localStorage.getItem('userToken'); + + const { topicId } = useParams(); + const { routePage } = useNavigator(); + const [searchParams, _] = useSearchParams(); + const location = useLocation(); + + const [isOpen, setIsOpen] = useState(true); + const [isEditPinDetail, setIsEditPinDetail] = useState(false); + const [selectedPinId, setSelectedPinId] = useState(null); + const [topicDetails, setTopicDetails] = useState( + null, + ); + + const { tags, setTags, onClickInitTags, onClickCreateTopicWithTags } = + useTags(); + const { setCoordinates } = useContext(CoordinatesContext); + const { width } = useSetLayoutWidth(SIDEBAR); + const { seeTogetherTopics } = useContext(SeeTogetherContext); + const { markers, removeMarkers, removeInfowindows } = + useContext(MarkerContext); + useSetNavbarHighlight('seeTogether'); + useResizeMap(); + + const goToHome = () => { + routePage('/'); + }; + + const getAndSetDataFromServer = async () => { + if (topicId === '-1' || !topicId) return; + + const requestTopicIds = accessToken + ? topicId + : seeTogetherTopics?.join(','); + + const topics = await getApi( + `/topics/ids?ids=${requestTopicIds}`, + ); + + setTopicDetails([...topics]); + setCoordinatesTopicDetailWithHashMap(topics); + }; + + const setCoordinatesTopicDetailWithHashMap = (topics: TopicDetailProps[]) => { + if (topicId === '-1' || !topicId) return; + + const newCoordinates: any = []; + + topics.forEach((topic: TopicDetailProps) => { + topic.pins.forEach((pin: PinProps) => { + newCoordinates.push({ + id: pin.id, + topicId: topic.id, + pinName: pin.name, + latitude: pin.latitude, + longitude: pin.longitude, + }); + }); + }); + + setCoordinates(newCoordinates); + }; + + const togglePinDetail = () => { + setIsOpen(!isOpen); + }; + + useEffect(() => { + const queryParams = new URLSearchParams(location.search); + + if (queryParams.has('pinDetail')) { + setSelectedPinId(Number(queryParams.get('pinDetail'))); + setIsOpen(true); + return; + } + + setSelectedPinId(null); + }, [searchParams]); + + useEffect(() => { + getAndSetDataFromServer(); + }, [topicId]); + + useEffect(() => { + setTags([]); + + if (markers && markers.length > 0) { + removeMarkers(); + removeInfowindows(); + } + }, []); + + if (!seeTogetherTopics || !topicId) return <>; + + if (seeTogetherTopics.length === 0 || topicId === '-1') { + return ( + + + + + + 버튼을 눌러 지도를 추가해보세요. + + + + + + + ); + } + + return ( + + + {tags.length > 0 && ( + + )} + }> + {topicDetails?.map((topicDetail, idx) => ( + + topicDetail.id) + .join(',')} + topicId={String(topicDetail.id)} + topicDetail={topicDetail} + setSelectedPinId={setSelectedPinId} + setIsEditPinDetail={setIsEditPinDetail} + setTopicsFromServer={getAndSetDataFromServer} + /> + {idx !== topicDetails.length - 1 ? : null} + + ))} + + + + + {selectedPinId && ( + <> + + ◀ + + + + + + )} + + ); +} + +const WrapperWhenEmpty = styled.section<{ width: '372px' | '100vw' }>` + width: ${({ width }) => `calc(${width} - 40px)`}; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + margin: 0 auto; +`; + +const Wrapper = styled.section<{ + width: 'calc(100vw - 40px)' | 'calc(372px - 40px)'; + $selectedPinId: number | null; +}>` + display: flex; + flex-direction: column; + width: ${({ width }) => width}; + margin: 0 auto; + + @media (max-width: 1076px) { + width: ${({ $selectedPinId }) => ($selectedPinId ? '49vw' : '50vw')}; + margin: ${({ $selectedPinId }) => $selectedPinId && '0'}; + } + + @media (max-width: 744px) { + width: 100%; + } +`; + +const PinDetailWrapper = styled.div` + &.collapsedPinDetail { + z-index: -1; + } +`; + +const ToggleButton = styled.button<{ + $isCollapsed: boolean; +}>` + position: absolute; + top: 50%; + left: 744px; + transform: translateY(-50%); + z-index: 1; + height: 80px; + background-color: #fff; + padding: 12px; + border-radius: 4px; + box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.2); + cursor: pointer; + + ${(props) => + props.$isCollapsed && + ` + transform: rotate(180deg); + top: 45%; + left: 372px; + z-index: 1; + `} + + &:hover { + background-color: #f5f5f5; + } + + @media (max-width: 1076px) { + display: none; + } +`; + +export default SeeTogether; diff --git a/frontend/src/pages/SeeTogetherTopics.tsx b/frontend/src/pages/SeeTogetherTopics.tsx deleted file mode 100644 index a4c5a0af..00000000 --- a/frontend/src/pages/SeeTogetherTopics.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { useContext } from 'react'; -import { SIDEBAR } from '../constants'; -import useNavigator from '../hooks/useNavigator'; -import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; -import { SeeTogetherContext } from '../context/SeeTogetherContext'; -import Text from '../components/common/Text'; -import SeeTogetherNotFilledSVG from '../assets/seeTogetherBtn_notFilled.svg'; -import { styled } from 'styled-components'; -import Flex from '../components/common/Flex'; -import Space from '../components/common/Space'; -import TopicCard from '../components/TopicCard'; -import Button from '../components/common/Button'; -import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; -import { deleteApi } from '../apis/deleteApi'; -import useToast from '../hooks/useToast'; -import { getApi } from '../apis/getApi'; -import { TopicCardProps } from '../types/Topic'; - -const SeeTogetherTopics = () => { - const { routePage } = useNavigator(); - const { width } = useSetLayoutWidth(SIDEBAR); - const { seeTogetherTopics, setSeeTogetherTopics } = - useContext(SeeTogetherContext); - const { showToast } = useToast(); - useSetNavbarHighlight('seeTogether'); - - const goToHome = () => { - routePage('/'); - }; - - const setTopicsFromServer = async () => { - try { - const topics = await getApi('/members/my/atlas'); - - setSeeTogetherTopics(topics); - } catch { - showToast('error', '로그인 후 이용해주세요.'); - } - }; - - const goToSelectedTopic = () => { - if (!seeTogetherTopics) return; - - const seeTogetherTopicIds = seeTogetherTopics - .map((topic) => topic.id) - .join(','); - - routePage(`/topics/${seeTogetherTopicIds}`, seeTogetherTopicIds); - }; - - const onClickDeleteSeeTogetherTopics = () => { - if (!seeTogetherTopics) return; - - const deleteTopics = seeTogetherTopics; - - try { - deleteTopics.forEach(async (topic) => { - await deleteApi(`/atlas/topics?id=${topic.id}`); - }); - - showToast('info', '모아보기를 비웠습니다.'); - - setSeeTogetherTopics([]); - } catch (e) { - showToast('info', '모아보기를 비우는데 실패했습니다.'); - } - }; - - if (!seeTogetherTopics) return <>; - - if (seeTogetherTopics.length === 0) { - return ( - - - - - - 버튼을 눌러 지도를 추가해보세요. - - - - - - - ); - } - - return ( - - - {seeTogetherTopics.map((topic, idx) => ( -
      - - {idx !== seeTogetherTopics.length - 1 ? : <>} -
    - ))} - - - - - - - - - -
    - ); -}; - -const Wrapper = styled.section<{ width: '372px' | '100vw' }>` - width: ${({ width }) => `calc(${width} - 40px)`}; - height: 100%; - display: flex; - flex-direction: column; - - margin: 0 auto; -`; - -const WrapperWhenEmpty = styled.section<{ width: '372px' | '100vw' }>` - width: ${({ width }) => `calc(${width} - 40px)`}; - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - - margin: 0 auto; -`; - -const ButtonsWrapper = styled.div` - display: flex; - justify-content: end; - align-items: center; -`; - -export default SeeTogetherTopics; diff --git a/frontend/src/pages/SelectedTopic.tsx b/frontend/src/pages/SelectedTopic.tsx index 57c5067e..45e63dd8 100644 --- a/frontend/src/pages/SelectedTopic.tsx +++ b/frontend/src/pages/SelectedTopic.tsx @@ -1,98 +1,77 @@ -import { - Fragment, - lazy, - Suspense, - useContext, - useEffect, - useState, -} from 'react'; -import { styled } from 'styled-components'; -import Space from '../components/common/Space'; -import { TopicDetailProps } from '../types/Topic'; +import { lazy, Suspense, useContext, useEffect, useState } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; -import PinDetail from './PinDetail'; +import { styled } from 'styled-components'; + import { getApi } from '../apis/getApi'; +import Space from '../components/common/Space'; import PullPin from '../components/PullPin'; +import PinsOfTopicSkeleton from '../components/Skeletons/PinsOfTopicSkeleton'; +import { LAYOUT_PADDING, SIDEBAR } from '../constants'; import { CoordinatesContext } from '../context/CoordinatesContext'; -import useNavigator from '../hooks/useNavigator'; +import useResizeMap from '../hooks/useResizeMap'; import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; -import { LAYOUT_PADDING, SIDEBAR } from '../constants'; import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; -import PinsOfTopicSkeleton from '../components/Skeletons/PinsOfTopicSkeleton'; -import { TagContext } from '../context/TagContext'; +import useTags from '../hooks/useTags'; import { PinProps } from '../types/Pin'; +import { TopicDetailProps } from '../types/Topic'; +import PinDetail from './PinDetail'; const PinsOfTopic = lazy(() => import('../components/PinsOfTopic')); -const SelectedTopic = () => { +function SelectedTopic() { const { topicId } = useParams(); const [searchParams, _] = useSearchParams(); - const [topicDetails, setTopicDetails] = useState( - null, - ); + const [topicDetail, setTopicDetail] = useState(null); const [selectedPinId, setSelectedPinId] = useState(null); const [isOpen, setIsOpen] = useState(true); const [isEditPinDetail, setIsEditPinDetail] = useState(false); - const { routePage } = useNavigator(); const { setCoordinates } = useContext(CoordinatesContext); - const { tags, setTags } = useContext(TagContext); const { width } = useSetLayoutWidth(SIDEBAR); - const { navbarHighlights: __ } = useSetNavbarHighlight('home'); + const { tags, setTags, onClickInitTags, onClickCreateTopicWithTags } = + useTags(); + useSetNavbarHighlight('none'); + useResizeMap(); const getAndSetDataFromServer = async () => { - const data = await getApi(`/topics/ids?ids=${topicId}`); - - const topicHashmap = new Map([]); + const topicInArray = await getApi( + `/topics/ids?ids=${topicId}`, + ); + const topic = topicInArray[0]; - setTopicDetails(data); + setTopicDetail(topic); + setCoordinatesTopicDetail(topic); + }; - // 각 topic의 pin들의 좌표를 가져옴 + const setCoordinatesTopicDetail = (topic: TopicDetailProps) => { const newCoordinates: any = []; - data.forEach((topic: TopicDetailProps) => { - topic.pins.forEach((pin: PinProps) => { - newCoordinates.push({ - id: pin.id, - topicId: topic.id, - pinName: pin.name, - latitude: pin.latitude, - longitude: pin.longitude, - }); + topic.pins.forEach((pin: PinProps) => { + newCoordinates.push({ + id: pin.id, + topicId, + pinName: pin.name, + latitude: pin.latitude, + longitude: pin.longitude, }); }); setCoordinates(newCoordinates); - - data.forEach((topicDetailFromData: TopicDetailProps) => - topicHashmap.set(`${topicDetailFromData.id}`, topicDetailFromData), - ); - - const topicDetailFromData = topicId - ?.split(',') - .map((number) => topicHashmap.get(number)) as TopicDetailProps[]; - - if (!topicDetailFromData) return; - - setTopicDetails([...topicDetailFromData]); - }; - - const onClickConfirm = () => { - routePage('/new-topic', tags.map((tag) => tag.id).join(',')); }; - const onTagCancel = () => { - setTags([]); + const togglePinDetail = () => { + setIsOpen(!isOpen); }; useEffect(() => { const queryParams = new URLSearchParams(location.search); + if (queryParams.has('pinDetail')) { setSelectedPinId(Number(queryParams.get('pinDetail'))); - } else { - setSelectedPinId(null); + setIsOpen(true); + return; } - setIsOpen(true); + setSelectedPinId(null); }, [searchParams]); useEffect(() => { @@ -100,12 +79,7 @@ const SelectedTopic = () => { setTags([]); }, []); - const togglePinDetail = () => { - setIsOpen(!isOpen); - }; - - if (!topicDetails) return <>; - if (!topicId) return <>; + if (!topicId || !topicDetail) return <>; return ( { )} }> - {topicDetails.map((topicDetail, idx) => ( - - - {idx !== topicDetails.length - 1 ? : <>} - - ))} + + + {selectedPinId && ( <> @@ -153,7 +124,7 @@ const SelectedTopic = () => { )} ); -}; +} const Wrapper = styled.section<{ width: 'calc(100vw - 40px)' | 'calc(372px - 40px)'; @@ -162,19 +133,16 @@ const Wrapper = styled.section<{ display: flex; flex-direction: column; width: ${({ width }) => width}; - margin: ${({ $selectedPinId }) => $selectedPinId === null && '0 auto'}; + margin: 0 auto; @media (max-width: 1076px) { - width: calc(50vw - 40px); + width: ${({ $selectedPinId }) => ($selectedPinId ? '49vw' : '50vw')}; + margin: ${({ $selectedPinId }) => $selectedPinId && '0'}; } @media (max-width: 744px) { width: 100%; } - - @media (max-width: 372px) { - width: ${({ width }) => width}; - } `; const PinDetailWrapper = styled.div` @@ -202,7 +170,7 @@ const ToggleButton = styled.button<{ props.$isCollapsed && ` transform: rotate(180deg); - top:45%; + top: 45%; left: 372px; z-index: 1; `} diff --git a/frontend/src/pages/UpdatedPinDetail.tsx b/frontend/src/pages/UpdatedPinDetail.tsx index 59d8cc8e..46069be7 100644 --- a/frontend/src/pages/UpdatedPinDetail.tsx +++ b/frontend/src/pages/UpdatedPinDetail.tsx @@ -1,15 +1,16 @@ +import { SetURLSearchParams } from 'react-router-dom'; +import styled from 'styled-components'; + +import usePut from '../apiHooks/usePut'; +import { putApi } from '../apis/putApi'; +import Button from '../components/common/Button'; import Flex from '../components/common/Flex'; import Space from '../components/common/Space'; import Text from '../components/common/Text'; -import { putApi } from '../apis/putApi'; -import { SetURLSearchParams } from 'react-router-dom'; -import { ModifyPinFormProps } from '../types/tmap'; import InputContainer from '../components/InputContainer'; -import { hasErrorMessage, hasNullValue } from '../validations'; import useToast from '../hooks/useToast'; -import Button from '../components/common/Button'; -import styled from 'styled-components'; -import usePut from '../apiHooks/usePut'; +import { ModifyPinFormProps } from '../types/FormValues'; +import { hasErrorMessage, hasNullValue } from '../validations'; interface UpdatedPinDetailProps { searchParams: URLSearchParams; @@ -26,7 +27,7 @@ interface UpdatedPinDetailProps { ) => void; } -const UpdatedPinDetail = ({ +function UpdatedPinDetail({ searchParams, pinId, formValues, @@ -35,7 +36,7 @@ const UpdatedPinDetail = ({ setIsEditing, updatePinDetailAfterEditing, onChangeInput, -}: UpdatedPinDetailProps) => { +}: UpdatedPinDetailProps) { const { showToast } = useToast(); const { fetchPut } = usePut(); @@ -46,7 +47,7 @@ const UpdatedPinDetail = ({ }; const onClickUpdatePin = async () => { - if (hasErrorMessage(errorMessages) || hasNullValue(formValues, 'images')) { + if (hasErrorMessage(errorMessages) || hasNullValue(formValues)) { showToast('error', '입력하신 항목들을 다시 한 번 확인해주세요.'); return; } @@ -72,33 +73,12 @@ const UpdatedPinDetail = ({ return ( - - - - + 사진을 추가해주시면 더 알찬 정보를 제공해줄 수 있을 것 같아요. - - - ); -}; +} const Wrapper = styled.div` margin: 0 auto; - + width: 100%; @media (max-width: 1076px) { width: calc(50vw - 40px); } diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 7f5833bd..dd9b8268 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -1,10 +1,11 @@ -import { Suspense, lazy } from 'react'; +import { lazy, ReactNode, Suspense } from 'react'; import { createBrowserRouter } from 'react-router-dom'; -import Home from './pages/Home'; -import RootPage from './pages/RootPage'; -import { ReactNode } from 'react'; + import AuthLayout from './components/Layout/AuthLayout'; +import Home from './pages/Home'; import NotFound from './pages/NotFound'; +import RootPage from './pages/RootPage'; +import Search from './pages/Search'; const SelectedTopic = lazy(() => import('./pages/SelectedTopic')); const NewPin = lazy(() => import('./pages/NewPin')); @@ -13,10 +14,10 @@ const SeeAllPopularTopics = lazy(() => import('./pages/SeeAllPopularTopics')); const SeeAllNearTopics = lazy(() => import('./pages/SeeAllNearTopics')); const SeeAllLatestTopics = lazy(() => import('./pages/SeeAllLatestTopics')); const KakaoRedirect = lazy(() => import('./pages/KakaoRedirect')); -const SeeTogetherTopics = lazy(() => import('./pages/SeeTogetherTopics')); const Profile = lazy(() => import('./pages/Profile')); const AskLogin = lazy(() => import('./pages/AskLogin')); const Bookmark = lazy(() => import('./pages/Bookmark')); +const SeeTogether = lazy(() => import('./pages/SeeTogether')); interface routeElement { path: string; @@ -31,9 +32,9 @@ interface SuspenseCompProps { children: ReactNode; } -const SuspenseComp = ({ children }: SuspenseCompProps) => { +function SuspenseComp({ children }: SuspenseCompProps) { return {children}; -}; +} const routes: routeElement[] = [ { @@ -102,15 +103,6 @@ const routes: routeElement[] = [ ), withAuth: false, }, - { - path: 'see-together', - element: ( - - - - ), - withAuth: true, - }, { path: 'favorite', element: ( @@ -147,6 +139,20 @@ const routes: routeElement[] = [ ), withAuth: false, }, + { + path: '/search', + element: , + withAuth: false, + }, + { + path: '/see-together/:topicId', + element: ( + + + + ), + withAuth: false, + }, ], }, ]; diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index fb075956..ba3b1cc3 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -1,7 +1,3 @@ import '@testing-library/jest-dom'; import '@testing-library/jest-dom/extend-expect'; import '@testing-library/react/cleanup-after-each'; - -// module.exports = { -// testEnvironment: 'jsdom', -// }; diff --git a/frontend/src/store/mapInstance.ts b/frontend/src/store/mapInstance.ts new file mode 100644 index 00000000..27b8f554 --- /dev/null +++ b/frontend/src/store/mapInstance.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +interface MapState { + mapInstance: TMap | null; + setMapInstance: (instance: TMap) => void; +} + +const useMapStore = create((set) => ({ + mapInstance: null, + setMapInstance: (instance: TMap) => set(() => ({ mapInstance: instance })), +})); + +export default useMapStore; diff --git a/frontend/src/themes/index.ts b/frontend/src/themes/index.ts index f6e43ce2..d63b572a 100644 --- a/frontend/src/themes/index.ts +++ b/frontend/src/themes/index.ts @@ -1,8 +1,8 @@ import color from './color'; import fontSize from './fontSize'; -import spacing from './spacing'; -import radius from './radius'; import fontWeight from './fontWeight'; +import radius from './radius'; +import spacing from './spacing'; const theme = { fontSize, diff --git a/frontend/src/types/Comment.ts b/frontend/src/types/Comment.ts new file mode 100644 index 00000000..bc6c5d1f --- /dev/null +++ b/frontend/src/types/Comment.ts @@ -0,0 +1,9 @@ +interface Comment { + id: number; + content: string; + creator: string; + creatorImageUrl: string; + parentPinCommentId: number | null; + canChange: boolean; + updatedAt: String; +} diff --git a/frontend/src/types/Map.ts b/frontend/src/types/Map.ts index ba5b370a..d03b56d3 100644 --- a/frontend/src/types/Map.ts +++ b/frontend/src/types/Map.ts @@ -1,7 +1,3 @@ -export interface MapAddressProps { - addressInfo: AddressInfoProps; -} - export interface AddressInfoProps { addressType: string; adminDong: string; @@ -21,45 +17,6 @@ export interface AddressInfoProps { roadName: string; } -export interface MapProps { - isMobile: boolean; - mouseClickFlag: boolean; - name: string; - _data: MapDataProps; - _object_: MapObjectProps; - _status: MapStatusProps; -} - -export interface MapDataProps { - mapType: number; - maxBounds: {}; - target: string; - container: {}; - vsmMap: {}; - vsmOptions: {}; - minZoomLimit: number; - maxZoomLimit: number; - options: MapOptionsProps; -} - -export interface MapObjectProps { - eventListeners: {}; - getHandlers: string; - fireEvent: string; -} - -export interface MapStatusProps { - zoom: number; - center: {}; - width: number; - height: number; -} - -export interface MapOptionsProps { - draggable: boolean; - measureControl: boolean; - naviControl: boolean; - pinchZoom: boolean; - scaleBar: boolean; - scrollwheel: boolean; +export interface MapAddressProps { + addressInfo: AddressInfoProps; } diff --git a/frontend/src/types/Pin.ts b/frontend/src/types/Pin.ts index e226333a..06c6dd23 100644 --- a/frontend/src/types/Pin.ts +++ b/frontend/src/types/Pin.ts @@ -1,3 +1,8 @@ +export interface ImageProps { + id: number; + imageUrl: string; +} + export interface PinProps { id: number; name: string; @@ -10,8 +15,3 @@ export interface PinProps { updatedAt: string; images: ImageProps[]; } - -export interface ImageProps { - id: number; - imageUrl: string; -} \ No newline at end of file diff --git a/frontend/src/types/Poi.ts b/frontend/src/types/Poi.ts new file mode 100644 index 00000000..470b9135 --- /dev/null +++ b/frontend/src/types/Poi.ts @@ -0,0 +1,77 @@ +export interface EvCharger { + evCharger: any[]; +} + +export interface NewAddress { + centerLat: string; + centerLon: string; + frontLat: string; +} + +export interface NewAddressList { + newAddress: NewAddress[]; +} + +export interface Poi { + id: string; + pkey: string; + navSeq: string; + collectionType: string; + name: string; + + adminDongCode: string; + bizName: string; + dataKind: string; + desc: string; + + evChargers: EvCharger; + firstBuildNo: string; + firstNo: string; + frontLat: string; + frontLon: string; + + legalDongCode: string; + + lowerAddrName: string; + + middleAddrName: string; + mlClass: string; + + newAddressList: NewAddressList; + + noorLat: string; + noorLon: string; + + parkFlag: string; + + radius: string; + + roadName: string; + + rpFlag: string; + + secondBuildNo: string; + + secondNo: string; + + telNo: string; + + upperAddrName: string; + + zipCode: String; +} + +export interface Pois { + poi: Poi[]; +} + +export interface SearchPoiInfo { + totalCount: string; + count: string; + page: string; + pois: Pois; +} + +export interface PoiApiResponse { + searchPoiInfo: SearchPoiInfo; +} diff --git a/frontend/src/types/index.d.ts b/frontend/src/types/index.d.ts index 990e1578..6319b25a 100644 --- a/frontend/src/types/index.d.ts +++ b/frontend/src/types/index.d.ts @@ -1,11 +1,13 @@ declare module '*.svg' { import React from 'react'; + const SVG: React.VFC>; export default SVG; } -// declare global { -// interface Window { -// Tmapv3: Tmapv3; -// } -// } +declare module '*.webp' { + import React from 'react'; + + const WEBP: string; + export default WEBP; +} diff --git a/frontend/src/types/tmap.d.ts b/frontend/src/types/tmap.d.ts index df11ab3e..4fc3b0ee 100644 --- a/frontend/src/types/tmap.d.ts +++ b/frontend/src/types/tmap.d.ts @@ -1,23 +1,14 @@ -interface Window { - Tmapv3: { - Map: new ( - element: HTMLElement, - options?: { center?: LatLng; scaleBar: boolean }, - ) => TMap; - LatLng: new (lat: number, lng: number) => LatLng; - LatLngBounds: new () => LatLngBounds; - Marker: new (options?: MarkerOptions) => Marker; - InfoWindow: new (options?: InfoWindowOptions) => InfoWindow; - Point: new (x: number, y: number) => Point; - }; - daum: any; +interface LatLng {} + +interface LatLngBounds { + extend(latLng: LatLng): void; } interface evt { data: { lngLat: { - _lat: number; - _lng: number; + lat: number; + lng: number; }; }; } @@ -29,14 +20,10 @@ interface TMap { fitBounds(bounds: LatLngBounds): void; setCenter(latLng: LatLng): void; setZoom(zoomLevel: number): void; - on(eventType: string, callback: (evt: evt) => void): void; - removeListener(eventType: string, callback: (evt: evt) => void): void; -} - -interface LatLng {} - -interface LatLngBounds { - extend(latLng: LatLng): void; + getZoom(): number; + on(eventType: string, callback: (event: evt) => void): void; + removeListener(eventType: string, callback: (event: evt) => void): void; + resize(width: number, height: number): void; } interface Marker { @@ -45,7 +32,7 @@ interface Marker { map?: Map; id?: string; getPosition(): LatLng; - on(eventType: string, callback: (evt: Event) => void): void; + on(eventType: string, callback: (event: evt) => void): void; setMap(mapOrNull?: Map | null): void; } @@ -66,3 +53,18 @@ interface InfoWindow { open(map?: Map, marker?: Marker, latlng?: LatLng): void; close(): void; } + +interface Window { + Tmapv3: { + Map: new ( + element: HTMLElement, + options?: { center?: LatLng; scaleBar: boolean }, + ) => TMap; + LatLng: new (lat: number, lng: number) => LatLng; + LatLngBounds: new () => LatLngBounds; + Marker: new (options?: MarkerOptions) => Marker; + InfoWindow: new (options?: InfoWindowOptions) => InfoWindow; + Point: new (x: number, y: number) => Point; + }; + daum: any; +} diff --git a/frontend/src/utils/RouteChangeTracker.tsx b/frontend/src/utils/RouteChangeTracker.tsx index 2036ea43..f3831cb1 100644 --- a/frontend/src/utils/RouteChangeTracker.tsx +++ b/frontend/src/utils/RouteChangeTracker.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { useLocation } from 'react-router-dom'; import ReactGA from 'react-ga4'; +import { useLocation } from 'react-router-dom'; const RouteChangeTracker = () => { const location = useLocation(); diff --git a/frontend/src/utils/getCurrentLocation.ts b/frontend/src/utils/getCurrentLocation.ts index 7f99f394..7e069773 100644 --- a/frontend/src/utils/getCurrentLocation.ts +++ b/frontend/src/utils/getCurrentLocation.ts @@ -1,7 +1,7 @@ const getCurrentLocation = () => { const onSuccess = (pos: GeolocationPosition) => { - const latitude = pos.coords.latitude; - const longitude = pos.coords.longitude; + const { latitude } = pos.coords; + const { longitude } = pos.coords; }; const onFail = () => { diff --git a/frontend/src/validations/index.ts b/frontend/src/validations/index.ts index 8faab7d3..1c502deb 100644 --- a/frontend/src/validations/index.ts +++ b/frontend/src/validations/index.ts @@ -3,26 +3,20 @@ const REG_EXP_CURSES = const REG_EXP_POLITICALLY = /(괴뢰|빨갱이|왜놈|일베|조센징|쪽바리|짱깨|월북|매국노|메갈|섹스|쎅쓰|쎅스|섹쓰)/; -export const validateCurse = (userInput: string) => { - return REG_EXP_CURSES.test(userInput); -}; +export const validateCurse = (userInput: string) => + REG_EXP_CURSES.test(userInput); -export const validatePolitically = (userInput: string) => { - return REG_EXP_POLITICALLY.test(userInput); -}; +export const validatePolitically = (userInput: string) => + REG_EXP_POLITICALLY.test(userInput); -export const hasErrorMessage = (errorMessages: T) => { - return Object.values(errorMessages).some( - (errorMessage) => errorMessage.length > 0, - ); -}; +export const hasErrorMessage = (errorMessages: T) => + Object.values(errorMessages).some((errorMessage) => errorMessage.length > 0); export const hasNullValue = ( formValues: T, notRequiredKey?: keyof T, -) => { - return Object.entries(formValues).some(([key, value]) => { +) => + Object.entries(formValues).some(([key, value]) => { if (notRequiredKey && key === notRequiredKey) return false; return value.length === 0; }); -}; diff --git a/frontend/webpack.common.js b/frontend/webpack.common.js index 51c549b8..d9286c09 100644 --- a/frontend/webpack.common.js +++ b/frontend/webpack.common.js @@ -1,7 +1,7 @@ const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const { ProvidePlugin, DefinePlugin } = require('webpack'); -const Dotenv = require('dotenv-webpack'); +const DotenvWebpackPlugin = require('dotenv-webpack'); module.exports = { entry: { @@ -24,6 +24,9 @@ module.exports = { new DefinePlugin({ 'process.env.APP_URL': JSON.stringify(process.env.APP_URL), }), + new DotenvWebpackPlugin({ + systemvars: true, + }), ], resolve: { extensions: ['.tsx', '.ts', '.jsx', '.js'], @@ -42,7 +45,7 @@ module.exports = { }, }, { - test: /\.(png|jpe?g)$/, + test: /\.(png|jpe?g|webp)$/, type: 'asset', }, {