diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5c11f3d..8e39034 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,8 +3,7 @@ name: Deploy To EC2 on: push: branches: - - main - - improve/performance + - integrate-fe-be jobs: deploy: diff --git a/build.gradle b/build.gradle index d0272b9..1523775 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,7 @@ dependencies { implementation group: 'org.json', name: 'json', version: '20231013' implementation 'org.apache.httpcomponents.client5:httpclient5:5.2' implementation 'org.jsoup:jsoup:1.18.1' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/futsal/futsalMatch/config/platform/IamConfig.java b/src/main/java/futsal/futsalMatch/config/platform/IamConfig.java index b1c754b..bb46387 100644 --- a/src/main/java/futsal/futsalMatch/config/platform/IamConfig.java +++ b/src/main/java/futsal/futsalMatch/config/platform/IamConfig.java @@ -7,6 +7,7 @@ @Component public class IamConfig implements PlatformConfig { private final String platform = "IAM"; + private final String fullNameInKorean = "아이엠그라운드"; private final String date = "start_date"; private final String time = "start_time"; private final String region = "fAddress"; @@ -23,4 +24,6 @@ public class IamConfig implements PlatformConfig { private final String link = "s_match_num"; private final String requestBaseURL = "https://m.iamground.kr/api/v1/s_match/getsmatchlist"; private final String matchLinkBaseURL = "https://m.iamground.kr/futsal/s_match/detail/"; + + private final int priority = 5; } diff --git a/src/main/java/futsal/futsalMatch/config/platform/PlabConfig.java b/src/main/java/futsal/futsalMatch/config/platform/PlabConfig.java index 86eefad..b20b5ad 100644 --- a/src/main/java/futsal/futsalMatch/config/platform/PlabConfig.java +++ b/src/main/java/futsal/futsalMatch/config/platform/PlabConfig.java @@ -7,6 +7,7 @@ @Component public class PlabConfig implements PlatformConfig { private final String platform = "PLAB"; + private final String fullNameInKorean = "플랩풋볼"; private final String date = "schedule"; private final String time = "schedule"; private final String region = "area_group_name"; @@ -23,4 +24,10 @@ public class PlabConfig implements PlatformConfig { private final String link = "id"; private final String requestBaseURL = "https://www.plabfootball.com/api/v2/integrated-matches/"; private final String matchLinkBaseURL = "https://www.plabfootball.com/match/"; + + // Platform Specific + public static String productType = "product_type"; + public static String guestMatchLinkBaseURL = "https://www.plabfootball.com/guest-match/"; + + private final int priority = 1; } diff --git a/src/main/java/futsal/futsalMatch/config/platform/PlatformConfig.java b/src/main/java/futsal/futsalMatch/config/platform/PlatformConfig.java index 3c877ff..0737180 100644 --- a/src/main/java/futsal/futsalMatch/config/platform/PlatformConfig.java +++ b/src/main/java/futsal/futsalMatch/config/platform/PlatformConfig.java @@ -4,6 +4,7 @@ public interface PlatformConfig { String getPlatform(); + String getFullNameInKorean(); String getDate(); String getTime(); String getRegion(); @@ -24,5 +25,8 @@ public interface PlatformConfig { default Map getCustomFields() { return Map.of(); } + default int getPriority() { + return 999; + } } diff --git a/src/main/java/futsal/futsalMatch/config/platform/PuzzleConfig.java b/src/main/java/futsal/futsalMatch/config/platform/PuzzleConfig.java index 7a36abc..ad1eab0 100644 --- a/src/main/java/futsal/futsalMatch/config/platform/PuzzleConfig.java +++ b/src/main/java/futsal/futsalMatch/config/platform/PuzzleConfig.java @@ -9,6 +9,7 @@ @Component public class PuzzleConfig implements PlatformConfig { private final String platform = "PUZZLE"; + private final String fullNameInKorean = "퍼즐플레이"; private final String date = "match_date"; private final String time = "match_time"; private final String region = "ground_region"; @@ -32,4 +33,5 @@ public class PuzzleConfig implements PlatformConfig { "GYEONGGI_DONGBU_CODE", "65126e3929b8b579c68f372f" ); + private final int priority = 2; } diff --git a/src/main/java/futsal/futsalMatch/config/platform/UrbanConfig.java b/src/main/java/futsal/futsalMatch/config/platform/UrbanConfig.java index 6ea1656..fe66011 100644 --- a/src/main/java/futsal/futsalMatch/config/platform/UrbanConfig.java +++ b/src/main/java/futsal/futsalMatch/config/platform/UrbanConfig.java @@ -7,6 +7,7 @@ @Component public class UrbanConfig implements PlatformConfig { private final String platform = "URBAN"; + private final String fullNameInKorean = "어반풋볼"; private final String date = "span.date"; private final String time = "li.time > span"; private final String region = ""; @@ -23,4 +24,6 @@ public class UrbanConfig implements PlatformConfig { private final String link = "data_id"; private final String requestBaseURL = "https://urbanfootball.co.kr/result/result_get_data.php"; private final String matchLinkBaseURL = "https://urbanfootball.co.kr/goods/goods_view.html?goods_no="; + + private final int priority = 4; } diff --git a/src/main/java/futsal/futsalMatch/config/platform/WithConfig.java b/src/main/java/futsal/futsalMatch/config/platform/WithConfig.java index d1067ba..87b80a0 100644 --- a/src/main/java/futsal/futsalMatch/config/platform/WithConfig.java +++ b/src/main/java/futsal/futsalMatch/config/platform/WithConfig.java @@ -7,6 +7,7 @@ @Component public class WithConfig implements PlatformConfig { private final String platform = "WITH"; + private final String fullNameInKorean = "위드풋살"; private final String date = "date"; private final String time = "play_time"; private final String region = "mem_area"; @@ -23,4 +24,6 @@ public class WithConfig implements PlatformConfig { private final String link = "idx"; private final String requestBaseURL = "https://withfutsal.com/ajaxProcSocialMatch.php"; private final String matchLinkBaseURL = "https://withfutsal.com/Sub/ground.php?block_idx="; + + private final int priority = 3; } diff --git a/src/main/java/futsal/futsalMatch/controller/PlatformRequestController.java b/src/main/java/futsal/futsalMatch/controller/PlatformRequestController.java index 4d2c562..efc2b40 100644 --- a/src/main/java/futsal/futsalMatch/controller/PlatformRequestController.java +++ b/src/main/java/futsal/futsalMatch/controller/PlatformRequestController.java @@ -10,7 +10,10 @@ import java.time.LocalDate; import java.util.List; -@CrossOrigin(origins = "https://futsalfinder.co.kr/") +@CrossOrigin(origins = { + "http://localhost:8080", // 로컬 개발용 + "https://futsalfinder.co.kr", // 배포 도메인 +}) @RequiredArgsConstructor @RestController public class PlatformRequestController { @@ -19,8 +22,8 @@ public class PlatformRequestController { @GetMapping(value = "/matches/{date}", headers = {"Accept=application/json;charset=UTF-8"}) public ResponseEntity> getMatchInfo( @PathVariable("date") LocalDate date, - @RequestParam(value = "region") Integer region + @RequestParam(value = "region") int region ) { - return ResponseEntity.ok(platformRequestService.fetchAllData(date, Region.SEOUL)); + return ResponseEntity.ok(platformRequestService.fetchAllData(date, Region.fromValue(region))); } } diff --git a/src/main/java/futsal/futsalMatch/controller/ViewController.java b/src/main/java/futsal/futsalMatch/controller/ViewController.java new file mode 100644 index 0000000..b6a56d5 --- /dev/null +++ b/src/main/java/futsal/futsalMatch/controller/ViewController.java @@ -0,0 +1,32 @@ +package futsal.futsalMatch.controller; + +import futsal.futsalMatch.enums.Region; +import futsal.futsalMatch.service.CalendarService; +import futsal.futsalMatch.service.PlatformConfigService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +@Controller +@RequiredArgsConstructor +public class ViewController { + + private final CalendarService calendarService; + private final PlatformConfigService platformConfigService; + + @GetMapping("/") + public String index(Model model) { + LocalDate today = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDate(); + model.addAttribute("days", calendarService.getNextTwoWeeksDays(today)); + model.addAttribute("platforms", platformConfigService.getPlatformInfos()); + model.addAttribute("regions", Region.values()); + model.addAttribute("defaultRegion", Region.SEOUL); + + return "index"; + } +} diff --git a/src/main/java/futsal/futsalMatch/controller/dto/DayInfo.java b/src/main/java/futsal/futsalMatch/controller/dto/DayInfo.java new file mode 100644 index 0000000..7603e35 --- /dev/null +++ b/src/main/java/futsal/futsalMatch/controller/dto/DayInfo.java @@ -0,0 +1,7 @@ +package futsal.futsalMatch.controller.dto; + +import java.time.LocalDate; + +public record DayInfo(LocalDate date, Integer dayOfMonth, String dayWeek, Boolean isHoliday) { +} + diff --git a/src/main/java/futsal/futsalMatch/controller/dto/PlatformInfo.java b/src/main/java/futsal/futsalMatch/controller/dto/PlatformInfo.java new file mode 100644 index 0000000..0ef7f87 --- /dev/null +++ b/src/main/java/futsal/futsalMatch/controller/dto/PlatformInfo.java @@ -0,0 +1,4 @@ +package futsal.futsalMatch.controller.dto; + +public record PlatformInfo(String platform, String fullNameInKorean) { +} diff --git a/src/main/java/futsal/futsalMatch/enums/Region.java b/src/main/java/futsal/futsalMatch/enums/Region.java index f7861a3..0ddd5d7 100644 --- a/src/main/java/futsal/futsalMatch/enums/Region.java +++ b/src/main/java/futsal/futsalMatch/enums/Region.java @@ -1,5 +1,23 @@ package futsal.futsalMatch.enums; +import futsal.futsalMatch.exception.InvalidRegionException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor public enum Region { - ALL, SEOUL, GYEONGGI + ALL(0, "모든지역"), + SEOUL(1, "서울"), + GYEONGGI(2, "경기"); + + private final int value; + private final String name; + + public static Region fromValue(int value) { + for (Region region : Region.values()) { + if (region.value == value) return region; + } + throw new InvalidRegionException("Invalid Region value: " + value); + } } diff --git a/src/main/java/futsal/futsalMatch/exception/ErrorMessage.java b/src/main/java/futsal/futsalMatch/exception/ErrorMessage.java new file mode 100644 index 0000000..d3c2892 --- /dev/null +++ b/src/main/java/futsal/futsalMatch/exception/ErrorMessage.java @@ -0,0 +1,3 @@ +package futsal.futsalMatch.exception; +public record ErrorMessage(String message) { +} diff --git a/src/main/java/futsal/futsalMatch/exception/GlobalExceptionHandler.java b/src/main/java/futsal/futsalMatch/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..d597c46 --- /dev/null +++ b/src/main/java/futsal/futsalMatch/exception/GlobalExceptionHandler.java @@ -0,0 +1,14 @@ +package futsal.futsalMatch.exception; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(InvalidRegionException.class) + public ResponseEntity handleInvalidRegionException(InvalidRegionException e) { + return ResponseEntity.badRequest().body(new ErrorMessage(e.getMessage())); + } +} diff --git a/src/main/java/futsal/futsalMatch/exception/InvalidRegionException.java b/src/main/java/futsal/futsalMatch/exception/InvalidRegionException.java new file mode 100644 index 0000000..ef69cef --- /dev/null +++ b/src/main/java/futsal/futsalMatch/exception/InvalidRegionException.java @@ -0,0 +1,7 @@ +package futsal.futsalMatch.exception; + +public class InvalidRegionException extends RuntimeException { + public InvalidRegionException(String message) { + super(message); + } +} diff --git a/src/main/java/futsal/futsalMatch/service/CalendarService.java b/src/main/java/futsal/futsalMatch/service/CalendarService.java new file mode 100644 index 0000000..43c5570 --- /dev/null +++ b/src/main/java/futsal/futsalMatch/service/CalendarService.java @@ -0,0 +1,38 @@ +package futsal.futsalMatch.service; + +import futsal.futsalMatch.controller.dto.DayInfo; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.TextStyle; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +@Service +public class CalendarService { + + //TODO Cache 사용 + /*private final Map> cache = new ConcurrentHashMap<>(); + + public List getNextTwoWeeksDays(LocalDate from) { + cache.keySet().removeIf(date -> date.isBefore(from)); + return cache.computeIfAbsent(from, this::generateNextTwoWeeksDays); + }*/ + + public List getNextTwoWeeksDays(LocalDate from) { + return generateNextTwoWeeksDays(from); + } + + public List generateNextTwoWeeksDays(LocalDate from) { + List days = new ArrayList<>(); + for(int i = 0; i < 14; i++) { + LocalDate date = from.plusDays(i); + String dayOfTheWeek = date.getDayOfWeek().getDisplayName(TextStyle.SHORT, Locale.KOREAN).substring(0, 1); + Boolean isHoliday = false; //TODO 추후 구현 - 외부 API 연동 + + days.add(new DayInfo(date, date.getDayOfMonth(), dayOfTheWeek, isHoliday)); + } + return days; + } +} diff --git a/src/main/java/futsal/futsalMatch/service/PlatformConfigService.java b/src/main/java/futsal/futsalMatch/service/PlatformConfigService.java new file mode 100644 index 0000000..9a988c1 --- /dev/null +++ b/src/main/java/futsal/futsalMatch/service/PlatformConfigService.java @@ -0,0 +1,31 @@ +package futsal.futsalMatch.service; + +import futsal.futsalMatch.config.platform.PlatformConfig; +import futsal.futsalMatch.controller.dto.PlatformInfo; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class PlatformConfigService { + + private final List configs; + private final List platformInfos = new ArrayList<>(); + + @PostConstruct + public void init() { + configs.sort(Comparator.comparing(PlatformConfig::getPriority)); + for(PlatformConfig config : configs) { + platformInfos.add(new PlatformInfo(config.getPlatform(), config.getFullNameInKorean())); + } + } + + public List getPlatformInfos() { + return platformInfos; + } +} diff --git a/src/main/java/futsal/futsalMatch/service/strategy/parsing/WithParsingStrategy.java b/src/main/java/futsal/futsalMatch/service/strategy/parsing/WithParsingStrategy.java index 9479a85..9161813 100644 --- a/src/main/java/futsal/futsalMatch/service/strategy/parsing/WithParsingStrategy.java +++ b/src/main/java/futsal/futsalMatch/service/strategy/parsing/WithParsingStrategy.java @@ -12,6 +12,10 @@ public class WithParsingStrategy implements ParsingStrategy { @Override public List parse(String fetchData, LocalDate date) { + if(fetchData == null || fetchData.isEmpty()) { + return List.of(); + } + JSONObject jsonData = new JSONObject(fetchData.substring(11)); //starts with "200 OK OK,{JsonData...}" if(!jsonData.has("block_list")) { diff --git a/src/main/java/futsal/futsalMatch/service/strategy/transform/PlabTransformStrategy.java b/src/main/java/futsal/futsalMatch/service/strategy/transform/PlabTransformStrategy.java index 98c3cd3..8659852 100644 --- a/src/main/java/futsal/futsalMatch/service/strategy/transform/PlabTransformStrategy.java +++ b/src/main/java/futsal/futsalMatch/service/strategy/transform/PlabTransformStrategy.java @@ -1,5 +1,6 @@ package futsal.futsalMatch.service.strategy.transform; +import futsal.futsalMatch.config.platform.PlabConfig; import futsal.futsalMatch.config.platform.PlatformConfig; import org.json.JSONObject; import org.springframework.stereotype.Component; @@ -46,30 +47,36 @@ protected String resolveTime(PlatformConfig config, Object matchData) { @Override protected String resolveRegion(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.optString(config.getRegion()); //"서울", "경기" + return jsonObject.optString(config.getRegion(), null); //"서울", "경기" } @Override protected String resolveMatchTitle(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.optString(config.getMatchTitle()); + return jsonObject.optString(config.getMatchTitle(), null); } @Override protected String resolveMainStadium(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.optString(config.getMainStadium()); + return jsonObject.optString(config.getMainStadium(), null); } @Override protected String resolveSubStadium(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.optString(config.getSubStadium()); + return jsonObject.optString(config.getSubStadium(), null); } @Override protected String resolveMatchType(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; + + // 플랩풋볼 게스트 매치 + if(jsonObject.optString(PlabConfig.productType).equals("guest")) { + return "게스트 모집"; + } + String matchType = jsonObject.optString(config.getMatchType()); return switch(matchType) { @@ -110,30 +117,37 @@ protected String resolveLevel(PlatformConfig config, Object matchData) { @Override protected String resolveMatchVS(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.optString(config.getMatchVS()); + return jsonObject.optString(config.getMatchVS(), null); } @Override protected String resolveCurPlayer(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.optString(config.getCurPlayer()); + return jsonObject.optString(config.getCurPlayer(), null); } @Override protected String resolveMaxPlayer(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.optString(config.getMaxPlayer()); + return jsonObject.optString(config.getMaxPlayer(), null); } @Override protected String resolvePrice(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.optString(config.getPrice()); + return jsonObject.optString(config.getPrice(), null); } @Override protected String resolveLink(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return config.getMatchLinkBaseURL() + jsonObject.optString(config.getLink()); + String id = jsonObject.optString(config.getLink()); + + // 플랩풋볼 게스트 매치 + if(jsonObject.optString(PlabConfig.productType).equals("guest")) { + return PlabConfig.guestMatchLinkBaseURL + id; + } + + return config.getMatchLinkBaseURL() + id; } } diff --git a/src/main/java/futsal/futsalMatch/service/strategy/transform/PuzzleTransformStrategy.java b/src/main/java/futsal/futsalMatch/service/strategy/transform/PuzzleTransformStrategy.java index 13f361d..5c10c27 100644 --- a/src/main/java/futsal/futsalMatch/service/strategy/transform/PuzzleTransformStrategy.java +++ b/src/main/java/futsal/futsalMatch/service/strategy/transform/PuzzleTransformStrategy.java @@ -82,7 +82,7 @@ protected String resolveRegion(PlatformConfig config, Object matchData) { @Override protected String resolveMatchTitle(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.getJSONObject("ground_info").optString(config.getMatchTitle()); + return jsonObject.getJSONObject("ground_info").optString(config.getMatchTitle(), null); } @Override @@ -128,7 +128,7 @@ protected String resolveLevel(PlatformConfig config, Object matchData) { @Override protected String resolveMatchVS(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.optString(config.getMatchVS()); + return jsonObject.optString(config.getMatchVS(), null); } @Override @@ -153,18 +153,18 @@ protected String resolveCurPlayer(PlatformConfig config, Object matchData) { @Override protected String resolveMaxPlayer(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.getJSONObject("personnel").optString(config.getMaxPlayer()); //personnel {min: 10, max: 18} + return jsonObject.getJSONObject("personnel").optString(config.getMaxPlayer(), null); //personnel {min: 10, max: 18} } @Override protected String resolvePrice(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.optString(config.getPrice()); + return jsonObject.optString(config.getPrice(), null); } @Override protected String resolveLink(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return config.getMatchLinkBaseURL() + jsonObject.optString(config.getLink()); + return config.getMatchLinkBaseURL() + jsonObject.optString(config.getLink(), null); } } diff --git a/src/main/java/futsal/futsalMatch/service/strategy/transform/WithTransformStrategy.java b/src/main/java/futsal/futsalMatch/service/strategy/transform/WithTransformStrategy.java index 80b76bf..0e7c6b3 100644 --- a/src/main/java/futsal/futsalMatch/service/strategy/transform/WithTransformStrategy.java +++ b/src/main/java/futsal/futsalMatch/service/strategy/transform/WithTransformStrategy.java @@ -19,13 +19,13 @@ protected String resolvePlatform(PlatformConfig config, Object matchData) { @Override protected String resolveDate(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.optString(config.getDate()); //yyyy-mm-dd + return jsonObject.optString(config.getDate(), null); //yyyy-mm-dd } @Override protected String resolveTime(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.optString(config.getTime()); //hh:mm + return jsonObject.optString(config.getTime(), null); //hh:mm } @Override @@ -47,13 +47,13 @@ protected String resolveMatchTitle(PlatformConfig config, Object matchData) { @Override protected String resolveMainStadium(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.optString(config.getMainStadium()); + return jsonObject.optString(config.getMainStadium(), null); } @Override protected String resolveSubStadium(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.optString(config.getSubStadium()); + return jsonObject.optString(config.getSubStadium(), null); } @Override @@ -101,19 +101,19 @@ protected String resolveMatchVS(PlatformConfig config, Object matchData) { @Override protected String resolveCurPlayer(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.optString(config.getCurPlayer()); + return jsonObject.optString(config.getCurPlayer(), null); } @Override protected String resolveMaxPlayer(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.optString(config.getMaxPlayer()); + return jsonObject.optString(config.getMaxPlayer(), null); } @Override protected String resolvePrice(PlatformConfig config, Object matchData) { JSONObject jsonObject = (JSONObject) matchData; - return jsonObject.optString(config.getPrice()); + return jsonObject.optString(config.getPrice(), null); } @Override diff --git a/src/main/resources/static/css/date.css b/src/main/resources/static/css/date.css new file mode 100644 index 0000000..5eb5689 --- /dev/null +++ b/src/main/resources/static/css/date.css @@ -0,0 +1,114 @@ +/* 날짜 네비게이션 바 전체 컨테이너 */ +.date-nav-container { + display: flex; /* 가로 방향으로 아이템 배치 (화살표 + 날짜 버튼) */ + align-items: center; /* 수직 중앙 정렬 */ + justify-content: space-between; /* 좌우 화살표는 고정, 가운데 날짜 영역은 유동 */ + max-width: 1000px; /* 전체 최대 폭 제한 */ + margin: 0 auto 16px auto; /* 가운데 정렬 + 아래 여백 */ + padding: 0 16px; /* 좌우 패딩 */ + gap: 12px; /* 화살표와 버튼 사이 간격 */ +} + +/* 날짜 버튼 영역 감싸는 래퍼 (보이는 영역) */ +.date-button-wrapper { + display: flex; /* flex 유지 */ + overflow-x: auto; /* 수평 스크롤 허용 */ + scroll-snap-type: x mandatory; /* 좌우 스냅 설정 */ + -webkit-overflow-scrolling: touch; /* 모바일 부드러운 스크롤 */ + scroll-behavior: smooth; /* 부드러운 이동 */ + + scrollbar-width: none; /* Firefox용 스크롤 숨김 */ + -ms-overflow-style: none; /* IE용 스크롤 숨김 */ +} + +.date-button-wrapper::-webkit-scrollbar { + display: none; /* Chrome, Safari */ +} + +/* 실제 날짜 버튼들이 배치되는 줄 */ +.date-buttons { + display: flex; /* 날짜 버튼들을 가로로 배치 */ + gap: 30px; /* 각 버튼 사이 간격 */ + transition: transform 0.2s ease; /* 좌우 이동 애니메이션 (슬라이드 효과) */ + min-width: fit-content; /* 자식 크기만큼 너비 자동 확장 */ +} + +/* 개별 날짜 버튼 */ +.date-buttons button.day { + flex: 0 0 auto; /* 스냅을 위해 고정 너비 */ + scroll-snap-align: start; /* 버튼의 시작 지점에 스냅 */ + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100px; + height: 80px; + border: none; + border-radius: 9999px; + background-color: transparent; + cursor: pointer; + font-family: inherit; + line-height: 1.1; + padding: 0; +} + +/* 날짜 숫자 (ex: 6, 10 등) */ +.date-buttons button.day .day-date { + font-size: 15px; /* 글자 크기 */ + font-weight: bold; /* 굵은 글씨 */ + line-height: 1; /* 줄간격 제거로 위아래 밀착 */ + color: black; +} + +/* 요일 (ex: 화, 수 등) */ +.date-buttons button.day .day-week { + font-size: 12px; /* 글자 크기 작게 */ + opacity: 0.8; /* 살짝 연하게 표시 */ + margin-top: 1px; /* 위 숫자와 약간 띄우기 */ + line-height: 1; /* 줄간격 없음 */; + font-weight: bold; /* 굵은 글씨 */ + color: black; +} + + +/* 토요일: 파란색 */ +.date-buttons button.day.saturday .day-date, +.date-buttons button.day.saturday .day-week { + color: #0000FF; +} + +/* 일요일: 빨간색 */ +.date-buttons button.day.sunday .day-date, +.date-buttons button.day.sunday .day-week { + color: #ff0000; +} + +/* 선택된 날짜 버튼 스타일 */ +.date-buttons button.day.selected { + background-color: #007bff; /* 파란색 배경 */ + color: white; /* 텍스트 흰색 */ +} + +/* 좌우 화살표 버튼 */ +.arrow { + width: 32px; /* 크기 고정 */ + height: 32px; + font-size: 24px; /* 화살표 기호 크게 보이게 */ + background: none; /* 배경 없음 */ + border: none; /* 테두리 없음 */ + color: #888; /* 기본 회색 */ + cursor: pointer; /* 커서 포인터로 변경 */ +} + +/* 비활성화된 화살표 버튼 */ +.arrow:disabled { + color: #ccc; /* 더 연한 색상으로 비활성 표시 */ + cursor: default; /* 커서 기본으로 변경 (포인터 아님) */ +} + + + + + + + diff --git a/src/main/resources/static/css/filter.css b/src/main/resources/static/css/filter.css new file mode 100644 index 0000000..b474b8b --- /dev/null +++ b/src/main/resources/static/css/filter.css @@ -0,0 +1,114 @@ +/* 필터 (지역, 성별, 플랫폼, 마감 가리기) 정렬 */ +.filter-container { + display: flex; /* 가로로 배치 */ + justify-content: flex-end; /* 오른쪽 정렬 */ + gap: 12px; /* 셀렉트박스 사이 여백 */ + margin-bottom: 20px; /* 아래쪽 여백 */ +} + +/* 마감 가리기 기본 상태: 회색 */ +.hide-btn { + border: 2px solid #B0B8C1; + border-radius: 20px; + padding: 6px 16px; + background-color: white; + color: #6B7684; + font-size: 14px; + cursor: pointer; + transition: all 0.25s ease; + font-weight: 500; +} + +/* 마감 가리기 활성 상태: 파란색 */ +.hide-btn.active { + border-color: #0064FF; + color: #0064FF; +} +/* 기본 필터 버튼 스타일 */ +.filter-chip { + border: 1.8px solid #D1D5DB; + background-color: white; + color: #6B7684; + font-size: 14px; + font-weight: 500; + padding: 6px 16px; + border-radius: 9999px; + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + transition: all 0.25s ease; +} + +/* 필터 적용됨 (활성화 상태) */ +.filter-chip.active { + border-color: #0064FF; + color: #0064FF; +} + +.filter-chip.active .filter-chip-arrow{ + content: url("/img/filter-chip-arrow-selected.svg"); +} + +/****************/ +.modal-overlay { + display: none; /* 기본은 숨김 */ + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.5); /* 반투명 배경 */ + z-index: 999; + justify-content: center; + align-items: center; +} + +.modal-overlay.active { + display: flex; +} + +.modal-content { + background: white; + border-radius: 20px; + width: 90%; + max-width: 400px; + padding: 20px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: bold; + font-size: 18px; + margin-bottom: 20px; +} + +.close-btn { + background: none; + border: none; + font-size: 20px; + cursor: pointer; +} + +.modal-body label { + display: flex; + align-items: center; + font-size: 16px; + padding: 8px 0; +} + +.modal-footer { + margin-top: 20px; +} + +.apply-btn { + width: 100%; + padding: 10px; + background-color: #007bff; + color: white; + font-weight: bold; + font-size: 15px; + border: none; + border-radius: 12px; + cursor: pointer; +} \ No newline at end of file diff --git a/src/main/resources/static/css/loading.css b/src/main/resources/static/css/loading.css new file mode 100644 index 0000000..e37591e --- /dev/null +++ b/src/main/resources/static/css/loading.css @@ -0,0 +1,28 @@ +.loading-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(255,255,255,0.6); + z-index: 1000; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + pointer-events: all; +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid #ccc; + border-top-color: #007bff; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-bottom: 12px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} \ No newline at end of file diff --git a/src/main/resources/static/css/match.css b/src/main/resources/static/css/match.css new file mode 100644 index 0000000..eb5c85a --- /dev/null +++ b/src/main/resources/static/css/match.css @@ -0,0 +1,86 @@ +.match-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 8px; + border-bottom: 1px solid #eee; +} + +/* 왼쪽: 시간 + 플랫폼 */ +.match-left { + display: flex; + align-items: center; + justify-content: flex-start; /* 왼쪽 정렬 */ + width: 120px; /* 고정 너비 */ + gap: 12px; /* 시간과 뱃지 사이 간격 */ + padding: 0 8px; + box-sizing: border-box; +} + +.match-time { + font-weight: bold; + font-size: 18px; + font-family: monospace, sans-serif; + color: #111; + white-space: nowrap; +} + +.platform-badge { + width: 72px; /* 고정 너비 */ + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 9999px; + font-weight: bold; + font-size: 13px; + white-space: nowrap; /* 줄바꿈 방지 */ + flex-shrink: 0; /* 축소 방지 */ + + background-color: #808080; + color: white; +} + +.platform-badge.PLAB { + background-color: #6799FF; +} + +.platform-badge.PUZZLE { + background-color: #86E57F; +} + +.platform-badge.WITH { + background-color: #FFE400; +} + +.platform-badge.URBAN { + background-color: #ff4400; +} +.platform-badge.IAM { + background-color: #6ea8fe; +} + +/* 가운데: 제목 + 상세 */ +.match-center { + flex-grow: 1; + text-align: center; +} + +.match-title { + font-weight: 600; + font-size: 15px; +} + +.match-detail { + font-size: 13px; + color: #666; + margin-top: 4px; +} + +/* 오른쪽: 인원 */ +.match-right { + min-width: 60px; + text-align: right; + font-weight: bold; + font-size: 14px; +} diff --git a/src/main/resources/static/img/filter-chip-arrow-selected.svg b/src/main/resources/static/img/filter-chip-arrow-selected.svg new file mode 100644 index 0000000..955634b --- /dev/null +++ b/src/main/resources/static/img/filter-chip-arrow-selected.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/img/filter-chip-arrow.svg b/src/main/resources/static/img/filter-chip-arrow.svg new file mode 100644 index 0000000..34a3c10 --- /dev/null +++ b/src/main/resources/static/img/filter-chip-arrow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html deleted file mode 100644 index 7560ce4..0000000 --- a/src/main/resources/static/index.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - -hello! - - \ No newline at end of file diff --git a/src/main/resources/static/js/date.js b/src/main/resources/static/js/date.js new file mode 100644 index 0000000..10ac640 --- /dev/null +++ b/src/main/resources/static/js/date.js @@ -0,0 +1,57 @@ +const itemsPerPage = 7; +const itemsPerSlide = 1; +let currentIndex = 0; // 이동 인덱스 + +function getStepSize() { + const allButtons = document.querySelectorAll("#dateButtonList .day"); + if (allButtons.length < 2) return allButtons[0]?.offsetWidth || 0; + + const first = allButtons[0]; + const second = allButtons[1]; + + const step = second.getBoundingClientRect().left - first.getBoundingClientRect().left; + return step; +} + +function scrollToIndex(index) { + const wrapper = document.querySelector(".date-button-wrapper"); + const step = getStepSize(); + const offset = index * step; + + wrapper.scrollTo({ + left: offset, + behavior: 'smooth' + }); +} + +document.getElementById("prevBtn").addEventListener("click", () => { + if (currentIndex > 0) { + currentIndex -= itemsPerSlide; + scrollToIndex(currentIndex) + } +}); + +document.getElementById("nextBtn").addEventListener("click", () => { + const allButtons = document.querySelectorAll("#dateButtonList .day"); + const maxIndex = allButtons.length - itemsPerPage; + if (currentIndex < maxIndex) { + currentIndex += itemsPerSlide; + scrollToIndex(currentIndex) + } +}); + +function resizeDayButtons() { + const wrapper = document.querySelector(".date-button-wrapper"); + const buttons = document.querySelectorAll("#dateButtonList .day"); + + if (!wrapper || buttons.length === 0) return; + + const wrapperWidth = wrapper.offsetWidth; + const gap = 30; // 버튼 사이 간격 (CSS의 .date-buttons gap과 동일) + const totalGap = gap * (7 - 1); // 7개의 버튼 사이에 6개의 gap + const buttonWidth = Math.floor((wrapperWidth - totalGap) / 7); + + buttons.forEach(btn => { + btn.style.width = `${buttonWidth}px`; + }); +} \ No newline at end of file diff --git a/src/main/resources/static/js/filter.js b/src/main/resources/static/js/filter.js new file mode 100644 index 0000000..6944f83 --- /dev/null +++ b/src/main/resources/static/js/filter.js @@ -0,0 +1,127 @@ +const filterState = {}; // ex: { gender: { all: [...], unchecked: [...] }, platform: { ... } } + +// 필터 상태 초기화 (최초 1회), 전역 필터링 상태 저장 -> renderMatches() 호출 시 전역 필터 상태 기반 필터링 +function initFilterState(modalId) { + const modal = document.getElementById(modalId); + const checkboxes = modal.querySelectorAll("input[type=checkbox]"); + const all = Array.from(checkboxes).map(cb => cb.value); + const unchecked = all.filter(value => !modal.querySelector(`input[value="${value}"]`).checked); + + filterState[modalId] = { all, unchecked }; +} + +// 체크박스 렌더링 (모달 열기 시) +function renderFilterState(modalId) { + const state = filterState[modalId]; + const modal = document.getElementById(modalId); + if (!state) return; + if (!modal) return; + + //unchecked 에 포함되지 않은 값들만 체크한다. + modal.querySelectorAll("input[type=checkbox]").forEach(cb => { + cb.checked = !state.unchecked.includes(cb.value); + }); +} + +// 선택된 상태 저장 (적용하기 버튼) +function saveFilterState(modalId) { + const modal = document.getElementById(modalId); + const unchecked = []; + + modal.querySelectorAll("input[type=checkbox]").forEach(cb => { + if (!cb.checked) unchecked.push(cb.value); + }); + + filterState[modalId].unchecked = unchecked; +} + +document.addEventListener("DOMContentLoaded", () => { + // 필터 모달 초기화 (모달 id 기준) + // 모든 .modal-overlay를 자동 탐색하여 초기화 + document.querySelectorAll(".modal-overlay").forEach(modal => { + initFilterState(modal.id); + }); + + // 모달 열기 버튼 + document.querySelectorAll("[data-modal-target]").forEach(btn => { + btn.addEventListener("click", () => { + const selector = btn.getAttribute("data-modal-target"); + const modal = document.querySelector(selector); + if (modal) { + renderFilterState(modal.id); // 상태 복원 + modal.classList.add("active"); + //CSS .modal-overlay.active -> 화면에 display 되도록, modal-overlay는 선택시 나타나는 전체 회색배경. + //이 배경이 active 되면 내부 modal-content도 함께 나타난다. + } + }); + }); + + // 모달 닫기 버튼 + document.querySelectorAll(".modal-overlay .close-btn").forEach(btn => { + btn.addEventListener("click", () => { + const modal = btn.closest(".modal-overlay"); + if (modal) { + //닫기 버튼 클릭시 체크 박스 상태는 저장하지 않는다. + modal.classList.remove("active"); + } + }); + }); + + // 모달 바깥 클릭 닫기 + document.querySelectorAll(".modal-overlay").forEach(modal => { + modal.addEventListener("click", (e) => { + if (e.target === modal) { + //바깥 클릭으로 닫을시 체크 박스 상태는 저장하지 않는다. + modal.classList.remove("active"); + } + }); + }); + + // 적용하기 버튼 클릭 → 저장 + document.querySelectorAll(".modal-overlay .apply-btn").forEach(btn => { + btn.addEventListener("click", () => { + const modal = btn.closest(".modal-overlay"); + if (modal) { + saveFilterState(modal.id); // 상태 저장 + modal.classList.remove("active"); + + // 필터링 선택 버튼 활성화 (적용 사항 있을시) + const triggerBtn = document.querySelector(`[data-modal-target="#${modal.id}"]`); + const isFiltered = filterState[modal.id].unchecked.length > 0 + if (triggerBtn) { + triggerBtn.classList.toggle("active", isFiltered); + } + + renderMatches(); + console.log(`[${modal.id}] 필터 적용됨:`, getActiveValues(modal.id)); + } + }); + }); +}) + +function applyFilters(matchList) { + const activeGenders = getActiveValues("genderModal"); + const activePlatforms = getActiveValues("platformModal"); + const hideFull = document.getElementById("hideFullToggle").classList.contains("active"); + + if(activeGenders.length === 0 || activePlatforms.length === 0) return []; + + return matchList + .filter(match => activeGenders.includes(match.sex)) + .filter(match => activePlatforms.includes(match.platform)) + .filter(match => !(hideFull && match.isFull)); //마감가리기 활성화 && 마감된 매치일 시 필터링 +} + +// 현재 적용된 항목만 가져오는 헬퍼 함수 +function getActiveValues(modalId) { + const state = filterState[modalId]; + if (!state) return []; + return state.all.filter(value => !state.unchecked.includes(value)); +} + +document.getElementById("hideFullToggle").addEventListener("click", function () { + this.classList.toggle("active"); + + renderMatches(); +}); + diff --git a/src/main/resources/static/js/home.js b/src/main/resources/static/js/home.js new file mode 100644 index 0000000..8acfdcd --- /dev/null +++ b/src/main/resources/static/js/home.js @@ -0,0 +1,145 @@ +let allMatches = []; +let isFetching = false; + +function fetchMatches(date) { + if(isFetching) return; + isFetching = true; + + document.getElementById("loadingOverlay").style.display = "flex"; // 로딩 화면 표시 + + const region = document.getElementById("region").value; + + const url = `/matches/${encodeURIComponent(date)}?region=${encodeURIComponent(region)}`; + + fetch(url) + .then(response => { + if (!response.ok) throw new Error("Network response was not ok"); + return response.json(); + }) + .then(data => { + allMatches = data.map(match => ({ + ...match, + isFull: parseInt(match.cur_player) >= parseInt(match.max_player) //마감 여부 추가. '마감가리기' 필터용 + })); + renderMatches(); + }) + .catch(error => { + console.error("Fetch error:", error); + }) + .finally(() => { + isFetching = false; + document.getElementById("loadingOverlay").style.display = "none"; // 로딩 화면 해제 + }); +} + +function renderMatches() { + const container = document.getElementById("match-list"); + container.innerHTML = ""; // 기존 내용 비우기 + + // 필터링 적용 + const filtered = applyFilters(allMatches); + + if (filtered.length === 0) { + container.innerHTML = "

조건에 맞는 매치가 없습니다.

"; + return; + } + + filtered.forEach(match => { + container.appendChild(createMatchElement(match)); + }); +} + +function createMatchElement(match) { + const level = match.level ?? ""; + const matchType = match.match_type ?? "" + const matchVs = match.match_vs ? `${match.match_vs}vs${match.match_vs}` : ""; + const curPlayer = match.cur_player ?? "?" + const maxPlayer = match.max_player ?? "?" + + const div = document.createElement("a"); + div.className = "match-item"; + div.href = `${match.link}`; // 예시: 이동할 링크 + div.target = "_blank"; // 새 탭에서 열기 + div.rel = "noopener noreferrer"; // 보안 및 성능을 위한 권장 설정 + div.style.textDecoration = "none"; + div.style.color = "inherit"; + + div.innerHTML = ` +
+
${match.time}
+
${match.platform}
+
+ +
+
${match.match_title}
+
${match.sex}·${level}·${matchType}·${matchVs}
+
+ +
+
${curPlayer} / ${maxPlayer}
+
+ `; + + return div; +} + +function initializeDateUI() { + resizeDayButtons(); + scrollToIndex(0); +} + +function setupDateSelection() { + document.querySelectorAll(".day").forEach(btn => { + btn.addEventListener("click", () => { + //다른 날짜 요청이 완료되지 않았으면 잠시 대기 + if(isFetching) return; + + // 이미 선택된 날짜면 무시 + if (btn.classList.contains("selected")) return; + + //이전에 선택된 날짜를 지운다. + document.querySelectorAll(".day").forEach(b => b.classList.remove("selected")); + + //현재 클릭한 날짜를 선택한다. + btn.classList.add("selected"); + + //선택한 날짜로 fetch 한다. + fetchMatches(btn.dataset.date); + }); + }); +} + +function setupRegionSelection() { + const regionSelect = document.getElementById("region"); + + regionSelect.addEventListener("change", () => { + if (isFetching) return; + + // 현재 선택된 날짜 요소에서 date 값을 추출 + const selectedDayBtn = document.querySelector(".day.selected"); + if (!selectedDayBtn) return; + + // 선택한 날짜 기준으로 새 region으로 fetch + fetchMatches(selectedDayBtn.dataset.date); + }); +} + +function selectFirstDate() { + const first = document.querySelector(".day"); + if (first) { + first.click(); + } +} + +// 실행 시점 +window.addEventListener("DOMContentLoaded", () => { + initializeDateUI(); + setupDateSelection(); + setupRegionSelection(); + selectFirstDate(); +}); + +window.addEventListener("resize", initializeDateUI); + + + diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 0000000..60ae5f6 --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,109 @@ + + + + + FutsalFinder + + + + + + + + + +
+ + +
+
+ + + +
+
+ + +
+ +
+ + + + + + + + + + + + + +
+ +
+ +
+ + + + + +