diff --git a/backend/backup.sql b/backend/backup.sql index 0aad9380..1292edbc 100644 --- a/backend/backup.sql +++ b/backend/backup.sql @@ -28,6 +28,7 @@ DROP TABLE IF EXISTS `admin_SEQ`; DROP TABLE IF EXISTS `admin`; DROP TABLE IF EXISTS `UserEntity`; DROP TABLE IF EXISTS `professor_SEQ`; +DROP TABLE IF EXISTS `news`; -- Admin table CREATE TABLE `admin` ( @@ -119,4 +120,16 @@ CREATE TABLE `reservation` ( CONSTRAINT `FK_reservation_seminar_room` FOREIGN KEY (`seminar_room_id`) REFERENCES `seminar_room` (`seminar_room_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci; --- Continue for other tables like `thesis`, `board`, and `users` \ No newline at end of file +-- Continue for other tables like `thesis`, `board`, and `users` + +-- News 테이블 생성 +CREATE TABLE `news` ( + `news_id` BIGINT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255), + `content` TEXT, + `view` INT DEFAULT 0, + `link` VARCHAR(255), + `image` VARCHAR(1000), + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`news_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; \ No newline at end of file diff --git a/backend/src/main/java/org/example/backend/news/controller/NewsController.java b/backend/src/main/java/org/example/backend/news/controller/NewsController.java new file mode 100644 index 00000000..d25772c5 --- /dev/null +++ b/backend/src/main/java/org/example/backend/news/controller/NewsController.java @@ -0,0 +1,73 @@ +package org.example.backend.news.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.example.backend.common.dto.PageRequestDto; +import org.example.backend.common.dto.ResponseDto; +import org.example.backend.news.domain.dto.NewsReqDto; +import org.example.backend.news.domain.dto.NewsResDto; +import org.example.backend.news.service.NewsService; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +@Tag(name = "뉴스", description = "뉴스 API") +@RequestMapping("/api/news") +public class NewsController { + private final NewsService newsService; + + @Operation(summary = "뉴스 생성 API", description = "뉴스 생성") + @PostMapping(consumes = "multipart/form-data") + public ResponseEntity createNews( + @RequestPart(value = "newsReqDto") @Valid NewsReqDto newsReqDto, + @RequestPart(value = "newsImage", required = false) MultipartFile multipartFile + ) { + Long newsId = newsService.saveNews(newsReqDto, multipartFile); + return new ResponseEntity<>(newsId, HttpStatus.OK); + } + + @Operation(summary = "모든 뉴스 조회 API", description = "모든 뉴스의 리스트 반환") + @GetMapping + public ResponseDto> getAllBoards(@Valid @ModelAttribute PageRequestDto pageRequest) { + + Page newsList = newsService.getAllNewss(pageRequest.toPageable()); + return ResponseDto.ok(newsList.getNumber(), newsList.getTotalPages(), newsList.getContent()); + } + + @Operation(summary = "단일 뉴스 조회 API", description = "단일 뉴스의 리스트 반환") + @GetMapping("/{newsId}") + public ResponseEntity getNews(@PathVariable(name = "newsId") Long newsId) { + NewsResDto newsResDto = newsService.getNews(newsId); + return new ResponseEntity<>(newsResDto, HttpStatus.OK); + } + + @Operation(summary = "뉴스 정보 업데이트 API", description = "뉴스 정보 업데이트") + @PostMapping("/{newsId}") + public ResponseEntity updateNews(@PathVariable(name = "newsId") Long newsId, + @RequestPart(value = "newsReqDto") NewsReqDto newsReqDto, + @RequestPart(value = "newsImage", required = false) MultipartFile multipartFile) { + NewsResDto newsResDto = newsService.updateNews(newsId, newsReqDto, multipartFile); + return new ResponseEntity<>(newsResDto, HttpStatus.OK); + } + + @Operation(summary = "뉴스 삭제 API", description = "뉴스 삭제") + @DeleteMapping("/{newsId}") + public ResponseEntity deleteNews(@PathVariable(name = "newsId") Long newsId) { + newsService.deleteNews(newsId); + return new ResponseEntity<>(HttpStatus.OK); + } +} diff --git a/backend/src/main/java/org/example/backend/news/domain/dto/NewsReqDto.java b/backend/src/main/java/org/example/backend/news/domain/dto/NewsReqDto.java new file mode 100644 index 00000000..36a288d5 --- /dev/null +++ b/backend/src/main/java/org/example/backend/news/domain/dto/NewsReqDto.java @@ -0,0 +1,50 @@ +package org.example.backend.news.domain.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NewsReqDto { + + @NotBlank(message = "제목은 필수 입력값입니다.") + @Size(max = 200, message = "제목은 최대 200자 입력 가능합니다.") + private String title; + + @NotBlank(message = "내용은 필수 입력값입니다.") + @Size(max = 5000, message = "내용은 최대 5000자까지 입력 가능합니다.") + private String content; + + private String createDate; + private String link; + private String image; + + @Builder + private NewsReqDto(String title, String content, String createDate, + String link, String image) { + this.title = title; + this.content = content; + this.createDate = createDate; + this.link = link; + this.image = image; + } + + public static NewsReqDto of(String title, String content, String createDate, + String link, String image) { + return NewsReqDto.builder() + .title(title) + .content(content) + .createDate(createDate) + .link(link) + .image(image) + .build(); + } + + public void setImage(String image) { + this.image = image; + } +} diff --git a/backend/src/main/java/org/example/backend/news/domain/dto/NewsResDto.java b/backend/src/main/java/org/example/backend/news/domain/dto/NewsResDto.java new file mode 100644 index 00000000..1e170008 --- /dev/null +++ b/backend/src/main/java/org/example/backend/news/domain/dto/NewsResDto.java @@ -0,0 +1,44 @@ +package org.example.backend.news.domain.dto; + +import java.time.format.DateTimeFormatter; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.backend.news.domain.entity.News; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NewsResDto { + private Long id; + private String title; + private String content; + private int view; + private String createDate; + private String link; + private String image; + + @Builder + private NewsResDto(Long id, String title, int view, String content, String createDate, + String link, String image) { + this.id = id; + this.title = title; + this.content = content; + this.view = view; + this.createDate = createDate; + this.link = link; + this.image = image; + } + + public static NewsResDto of(News news) { + return NewsResDto.builder() + .id(news.getId()) + .title(news.getTitle()) + .content(news.getContent()) + .view(news.getView()) + .createDate(news.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))) + .link(news.getLink()) + .image(news.getImage()) + .build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/example/backend/news/domain/entity/News.java b/backend/src/main/java/org/example/backend/news/domain/entity/News.java new file mode 100644 index 00000000..1ccb8b81 --- /dev/null +++ b/backend/src/main/java/org/example/backend/news/domain/entity/News.java @@ -0,0 +1,67 @@ +package org.example.backend.news.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.backend.common.domain.BaseEntity; +import org.example.backend.news.domain.dto.NewsReqDto; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class News extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "news_id", nullable = false) + private Long id; + + @Column(name = "name") + private String title; + + @Column(name = "content") + private String content; + + @Column(name = "view") + private int view; + + @Column(name = "link") + private String link; + + @Column(name = "image", length = 1000) + private String image; + + + @Builder + private News(String title, String content, int view, String link, + String image) { + this.title = title; + this.content = content; + this.view = view; + this.link = link; + this.image = image; + } + + public static News of(NewsReqDto dto) { + return News.builder() + .title(dto.getTitle()) + .content(dto.getContent()) + .view(0) + .link(dto.getLink()) + .image(dto.getImage()) + .build(); + } + + public void update(NewsReqDto dto) { + this.title = dto.getTitle(); + this.content = dto.getContent(); + this.link = dto.getLink(); + this.image = dto.getImage(); + } +} diff --git a/backend/src/main/java/org/example/backend/news/exception/NewsException.java b/backend/src/main/java/org/example/backend/news/exception/NewsException.java new file mode 100644 index 00000000..e9116afd --- /dev/null +++ b/backend/src/main/java/org/example/backend/news/exception/NewsException.java @@ -0,0 +1,16 @@ +package org.example.backend.news.exception; + +import lombok.RequiredArgsConstructor; +import org.example.backend.common.exception.BaseException; +import org.example.backend.common.exception.BaseExceptionType; + +@RequiredArgsConstructor +public class NewsException extends BaseException { + + private final NewsExceptionType exceptionType; + + @Override + public BaseExceptionType exceptionType() { + return exceptionType; + } +} diff --git a/backend/src/main/java/org/example/backend/news/exception/NewsExceptionType.java b/backend/src/main/java/org/example/backend/news/exception/NewsExceptionType.java new file mode 100644 index 00000000..d65360e0 --- /dev/null +++ b/backend/src/main/java/org/example/backend/news/exception/NewsExceptionType.java @@ -0,0 +1,28 @@ +package org.example.backend.news.exception; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +import lombok.RequiredArgsConstructor; +import org.example.backend.common.exception.BaseExceptionType; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum NewsExceptionType implements BaseExceptionType { + + NOT_FOUND_NEWS(NOT_FOUND, "뉴스를 찾을 수 없습니다") + ; + + private final HttpStatus httpStatus; + private final String errorMessage; + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public String errorMessage() { + return errorMessage; + } +} diff --git a/backend/src/main/java/org/example/backend/news/repository/NewsRepository.java b/backend/src/main/java/org/example/backend/news/repository/NewsRepository.java new file mode 100644 index 00000000..2d98b2de --- /dev/null +++ b/backend/src/main/java/org/example/backend/news/repository/NewsRepository.java @@ -0,0 +1,7 @@ +package org.example.backend.news.repository; + +import org.example.backend.news.domain.entity.News; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NewsRepository extends JpaRepository { +} diff --git a/backend/src/main/java/org/example/backend/news/service/NewsService.java b/backend/src/main/java/org/example/backend/news/service/NewsService.java new file mode 100644 index 00000000..bbc05acd --- /dev/null +++ b/backend/src/main/java/org/example/backend/news/service/NewsService.java @@ -0,0 +1,70 @@ +package org.example.backend.news.service; + +import static org.example.backend.news.exception.NewsExceptionType.NOT_FOUND_NEWS; + +import lombok.RequiredArgsConstructor; +import org.example.backend.global.config.aws.S3Uploader; +import org.example.backend.news.domain.dto.NewsReqDto; +import org.example.backend.news.domain.dto.NewsResDto; +import org.example.backend.news.domain.entity.News; +import org.example.backend.news.exception.NewsException; +import org.example.backend.news.exception.NewsExceptionType; +import org.example.backend.news.repository.NewsRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NewsService { + private final NewsRepository newsRepository; + private final S3Uploader s3Uploader; + private static final String dirName = "news"; + + @Transactional + public Long saveNews(NewsReqDto newsReqDto, MultipartFile multipartFile) { + + if (multipartFile != null && !multipartFile.isEmpty()) { + String uploadImageUrl = s3Uploader.upload(multipartFile, dirName); + newsReqDto.setImage(uploadImageUrl); + } + News news = News.of(newsReqDto); + News savedNews = newsRepository.save(news); + return savedNews.getId(); + } + + public NewsResDto getNews(Long newsId) { + News news = findNewsById(newsId); + return NewsResDto.of(news); + } + + @Transactional + public NewsResDto updateNews(Long newsId, NewsReqDto newsReqDto, MultipartFile multipartFile) { + if (multipartFile != null && !multipartFile.isEmpty()) { + String uploadImageUrl = s3Uploader.upload(multipartFile, dirName); + newsReqDto.setImage(uploadImageUrl); + } + + News news = findNewsById(newsId); + news.update(newsReqDto); + return NewsResDto.of(news); + } + + public void deleteNews(Long newsId) { + News news = findNewsById(newsId); + newsRepository.delete(news); + } + + private News findNewsById(Long newsId) { + return newsRepository.findById(newsId) + .orElseThrow(() -> new NewsException(NOT_FOUND_NEWS)); + } + + public Page getAllNewss(Pageable pageable) { + return newsRepository.findAll(pageable) + .map(NewsResDto::of); + } +} \ No newline at end of file diff --git a/backend/src/main/resources/data.sql b/backend/src/main/resources/data.sql index e44c84bb..452805d1 100644 --- a/backend/src/main/resources/data.sql +++ b/backend/src/main/resources/data.sql @@ -16,6 +16,8 @@ DELETE FROM admin; DELETE FROM users; +DELETE +FROM news; -- Department 더미 데이터 (1개) @@ -313,4 +315,76 @@ VALUES ('2024-12-01 09:00:00', '2024-12-01 11:00:00', 'MEETING', '홍성무교 ('2024-12-06 10:30:00', '2024-12-06 12:30:00', 'MEETING', '김은희교수님 랩미팅', 1, 1, NOW(), NOW()), ('2024-12-07 13:30:00', '2024-12-07 15:30:00', 'MEETING', '김민수교수님 랩미팅', 1, 2, NOW(), NOW()), ('2024-12-08 11:00:00', '2024-12-08 13:00:00', 'MEETING', '전종훈교수님 랩미팅', 1, 1, NOW(), NOW()), - ('2024-12-09 16:00:00', '2024-12-09 18:00:00', 'MEETING', '서민석교수님 랩미팅', 1, 4, NOW(), NOW()); \ No newline at end of file + ('2024-12-09 16:00:00', '2024-12-09 18:00:00', 'MEETING', '서민석교수님 랩미팅', 1, 4, NOW(), NOW()); + +INSERT INTO news (name, content, view, link, image, created_at) +VALUES + ('세종대학교 바이오융합공학과, 혁신 연구 성과 발표', + '세종대학교 바이오융합공학과가 올해 혁신적인 연구 성과를 발표했습니다.', + 320, + 'https://example.com/news1', + 'https://dibb-bucket.s3.ap-northeast-2.amazonaws.com/news/image1.jpg', + NOW()), + + ('바이오융합공학과, 신입생 환영회 개최', + '세종대학교 바이오융합공학과가 신입생들을 위한 환영회를 열었습니다.', + 150, + 'https://example.com/news2', + 'https://dibb-bucket.s3.ap-northeast-2.amazonaws.com/news/image2.jpg', + NOW()), + + ('세종대, 바이오 분야 특화 교육 프로그램 개설', + '세종대학교가 바이오 분야를 중심으로 한 특화 교육 프로그램을 개설하였습니다.', + 200, + 'https://example.com/news3', + 'https://dibb-bucket.s3.ap-northeast-2.amazonaws.com/news/image3.jpg', + NOW()), + + ('바이오융합공학과 졸업생, 글로벌 기업 취업 성공', + '세종대학교 바이오융합공학과 졸업생이 글로벌 기업에 성공적으로 취업했습니다.', + 400, + 'https://example.com/news4', + 'https://dibb-bucket.s3.ap-northeast-2.amazonaws.com/news/image4.jpg', + NOW()), + + ('바이오융합공학과, 국제 학술대회 논문 발표', + '바이오융합공학과 교수진이 국제 학술대회에서 연구 논문을 발표했습니다.', + 280, + 'https://example.com/news5', + 'https://dibb-bucket.s3.ap-northeast-2.amazonaws.com/news/image5.jpg', + NOW()), + + ('세종대, 바이오 산업 연구 지원 강화', + '세종대학교는 바이오 산업 연구를 위한 지원을 강화하고 있습니다.', + 170, + 'https://example.com/news6', + 'https://dibb-bucket.s3.ap-northeast-2.amazonaws.com/news/image6.jpg', + NOW()), + + ('세종대 바이오융합공학과, 혁신적인 실험실 설립', + '세종대학교 바이오융합공학과가 혁신적인 실험실을 설립하였습니다.', + 310, + 'https://example.com/news7', + 'https://dibb-bucket.s3.ap-northeast-2.amazonaws.com/news/image7.jpg', + NOW()), + + ('세종대 바이오융합공학과, 신약 개발 프로젝트 시작', + '세종대학교 바이오융합공학과가 신약 개발 프로젝트를 시작했습니다.', + 250, + 'https://example.com/news8', + 'https://dibb-bucket.s3.ap-northeast-2.amazonaws.com/news/image8.jpg', + NOW()), + + ('세종대학교, 바이오융합공학과 신임 교수 임용', + '세종대학교는 바이오융합공학과에 새로운 교수님을 임용했습니다.', + 180, + 'https://example.com/news9', + 'https://dibb-bucket.s3.ap-northeast-2.amazonaws.com/news/image9.jpg', + NOW()), + + ('세종대 바이오융합공학과, 학술 논문 출판', + '바이오융합공학과 교수진이 학술 논문을 성공적으로 출판하였습니다.', + 210, + 'https://example.com/news10', + 'https://dibb-bucket.s3.ap-northeast-2.amazonaws.com/news/image10.jpg', + NOW()); \ No newline at end of file diff --git a/frontend/public/graduate-curriculum1.png b/frontend/public/graduate-curriculum1.png new file mode 100644 index 00000000..0214048b Binary files /dev/null and b/frontend/public/graduate-curriculum1.png differ diff --git a/frontend/public/graduate-curriculum2.png b/frontend/public/graduate-curriculum2.png new file mode 100644 index 00000000..277ac9a7 Binary files /dev/null and b/frontend/public/graduate-curriculum2.png differ diff --git a/frontend/src/AppContent.tsx b/frontend/src/AppContent.tsx index a1ca9214..36ef8e72 100644 --- a/frontend/src/AppContent.tsx +++ b/frontend/src/AppContent.tsx @@ -14,6 +14,7 @@ import Main from './pages/Main/Main'; import SignInPage from './pages/Auth/SignInPage'; import Hyperlink from './pages/Undergraduate/Hyperlink'; import GraduateOverview from './pages/Graduate/GraduateOverview'; +import GraduateCurriculum from './pages/Graduate/GraduateCurriculum'; import Overview from './pages/About/About'; import Professor from './pages/About/Faculty/Professor'; import NoticeBoard from './pages/News/NoticeBoard/NoticeBoard'; @@ -29,7 +30,10 @@ import ThesisCreate from './pages/News/Thesis/ThesisCreate'; import ThesisEdit from './pages/News/Thesis/ThesisEdit'; import ThesisDetail from './pages/News/Thesis/ThesisDetail'; import Organization from './pages/About/Organization/Organization'; + +import mainImage from './assets/images/main_picture.svg'; import Curriculum from './pages/Undergraduate/Curriculum/Curriculum'; + import NotFound from './components/Notfound/NotFound'; import SeminarList from './pages/Seminar/SeminarList'; import SeminarDetail from './pages/Seminar/SeminarDetail'; @@ -194,13 +198,17 @@ function AppContent() { path="/undergraduate/curriculum" element={} /> + {/* graduate */} } /> + } /> + {/* about */} } /> } /> } /> } /> + {/* news */} } /> } /> @@ -212,6 +220,7 @@ function AppContent() { } /> } /> } /> + {/* 어드민 권한 보호 Routes */} ({ x: 0, y: 0 }); + const [positionRoad, setPositionRoad] = useState({ x: 0, y: 0 }); + + const dragStateCur = useRef({ + isDragging: false, + startX: 0, + startY: 0, + scrollLeft: 0, + scrollTop: 0, + }); + + const dragStateRoad = useRef({ + isDragging: false, + startX: 0, + startY: 0, + scrollLeft: 0, + scrollTop: 0, + }); + + const curContainerRef = useRef(null); + const roadContainerRef = useRef(null); + + const MIN_SCALE = 0.5; + const MAX_SCALE = 2; + const SCALE_STEP = 0.2; + + const handleMouseDown = useCallback( + ( + e: React.MouseEvent, + dragState: React.RefObject, + containerRef: React.RefObject, + ) => { + if (!containerRef.current || !dragState.current) return; + + dragState.current.isDragging = true; + dragState.current.startX = e.pageX - containerRef.current.offsetLeft; + dragState.current.startY = e.pageY - containerRef.current.offsetTop; + containerRef.current.style.cursor = 'grabbing'; + }, + [], + ); + + const handleMouseMove = useCallback( + ( + e: React.MouseEvent, + dragState: React.RefObject, + containerRef: React.RefObject, + setPosition: React.Dispatch>, + currentScale: number, + ) => { + if (!dragState.current?.isDragging || !containerRef.current) return; + + e.preventDefault(); + + const x = e.pageX - containerRef.current.offsetLeft; + const y = e.pageY - containerRef.current.offsetTop; + + const walkX = (x - dragState.current.startX) / currentScale; + const walkY = (y - dragState.current.startY) / currentScale; + + setPosition((prev: Position) => ({ + x: prev.x + walkX, + y: prev.y + walkY, + })); + + dragState.current.startX = x; + dragState.current.startY = y; + }, + [], + ); + + const handleMouseUp = useCallback( + ( + dragState: React.RefObject, + containerRef: React.RefObject, + ) => { + if (!dragState.current || !containerRef.current) return; + + dragState.current.isDragging = false; + containerRef.current.style.cursor = 'grab'; + }, + [], + ); + + const handleTouchStart = useCallback( + (e: React.TouchEvent, dragState: React.RefObject) => { + if (!dragState.current) return; + + const touch = e.touches[0]; + dragState.current.isDragging = true; + dragState.current.lastTouch = { + x: touch.clientX, + y: touch.clientY, + }; + }, + [], + ); + + const handleTouchMove = useCallback( + ( + e: React.TouchEvent, + dragState: React.RefObject, + setPosition: React.Dispatch>, + currentScale: number, + ) => { + if (!dragState.current?.isDragging || !dragState.current.lastTouch) + return; + + e.preventDefault(); + + const touch = e.touches[0]; + const walkX = + (touch.clientX - dragState.current.lastTouch.x) / currentScale; + const walkY = + (touch.clientY - dragState.current.lastTouch.y) / currentScale; + + setPosition((prev: Position) => ({ + x: prev.x + walkX, + y: prev.y + walkY, + })); + + dragState.current.lastTouch = { + x: touch.clientX, + y: touch.clientY, + }; + }, + [], + ); + + const handleTouchEnd = useCallback( + (dragState: React.RefObject) => { + if (!dragState.current) return; + dragState.current.isDragging = false; + dragState.current.lastTouch = undefined; + }, + [], + ); + + const handleZoom = useCallback( + ( + type: 'in' | 'out', + currentScale: number, + setScale: React.Dispatch>, + setPosition: React.Dispatch>, + ) => { + if (type === 'in') { + setScale((prev) => Math.min(prev + SCALE_STEP, MAX_SCALE)); + } else { + setScale((prev) => { + const newScale = Math.max(prev - SCALE_STEP, MIN_SCALE); + if (newScale === MIN_SCALE) { + setPosition({ x: 0, y: 0 }); + } + return newScale; + }); + } + }, + [MIN_SCALE, MAX_SCALE, SCALE_STEP], + ); + + useEffect(() => { + const handleGlobalMouseUp = () => { + if (curContainerRef.current) { + curContainerRef.current.style.cursor = 'grab'; + dragStateCur.current.isDragging = false; + } + if (roadContainerRef.current) { + roadContainerRef.current.style.cursor = 'grab'; + dragStateRoad.current.isDragging = false; + } + }; + + document.addEventListener('mouseup', handleGlobalMouseUp); + document.addEventListener('touchend', () => { + handleTouchEnd(dragStateCur); + handleTouchEnd(dragStateRoad); + }); + + return () => { + document.removeEventListener('mouseup', handleGlobalMouseUp); + document.removeEventListener('touchend', () => { + handleTouchEnd(dragStateCur); + handleTouchEnd(dragStateRoad); + }); + }; + }, []); + + const renderImage = useCallback( + ( + type: 'curriculum' | 'roadmap', + { + src, + alt, + scale, + position, + dragState, + containerRef, + setPosition, + }: { + src: string; + alt: string; + scale: number; + position: Position; + dragState: React.RefObject; + containerRef: React.RefObject; + setPosition: React.Dispatch>; + }, + ) => ( + + handleMouseDown(e, dragState, containerRef) + } + onMouseMove={(e: React.MouseEvent) => + handleMouseMove(e, dragState, containerRef, setPosition, scale) + } + onMouseLeave={() => handleMouseUp(dragState, containerRef)} + onTouchStart={(e) => handleTouchStart(e, dragState)} + onTouchMove={(e) => handleTouchMove(e, dragState, setPosition, scale)} + onTouchEnd={() => handleTouchEnd(dragState)} + style={{ cursor: scale > MIN_SCALE ? 'grab' : 'default' }} + > + MIN_SCALE ? 'none' : 'auto', + }} + onError={(e) => { + e.currentTarget.src = src; + e.currentTarget.onerror = null; + }} + draggable="false" + /> + + + + handleZoom( + 'out', + scale, + type === 'curriculum' ? setScaleCur : setScaleRoad, + setPosition, + ) + } + disabled={scale <= MIN_SCALE} + aria-label={`${type} 축소`} + > + + + + handleZoom( + 'in', + scale, + type === 'curriculum' ? setScaleCur : setScaleRoad, + setPosition, + ) + } + disabled={scale >= MAX_SCALE} + aria-label={`${type} 확대`} + > + + + + + ), + [ + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + ], + ); + + return ( + + + {renderImage('curriculum', { + src: '/graduate-curriculum1.png', + alt: '대학원 커리큘럼', + scale: scaleCur, + position: positionCur, + dragState: dragStateCur, + containerRef: curContainerRef, + setPosition: setPositionCur, + })} + 바이오융합공학 교과과정표1 + + + + + + 교과과정표1 다운로드 + + + + + {renderImage('roadmap', { + src: '/graduate-curriculum2.png', + alt: '대학원 커리큘럼', + scale: scaleRoad, + position: positionRoad, + dragState: dragStateRoad, + containerRef: roadContainerRef, + setPosition: setPositionRoad, + })} + 바이오융합공학 교과과정표2 + + + + + + 교과과정표2 다운로드 + + + + ); +} + +export default GraduateCurriculum; diff --git a/frontend/src/pages/Graduate/GraduateCurriculumStyle.tsx b/frontend/src/pages/Graduate/GraduateCurriculumStyle.tsx new file mode 100644 index 00000000..1b04522a --- /dev/null +++ b/frontend/src/pages/Graduate/GraduateCurriculumStyle.tsx @@ -0,0 +1,228 @@ +import styled from 'styled-components'; + +const media = { + mobile: '@media(max-width: 768px)', + tablet: '@media(max-width: 1024px)', +}; + +export const Container = styled.div` + max-width: 1400px; + width: 95%; + margin: 0 auto; + padding: 40px 20px; + display: flex; + flex-direction: column; + gap: 3rem; + + ${media.mobile} { + width: 100%; + padding: 20px 16px; + gap: 2rem; + } + + ${media.tablet} { + width: 90%; + padding: 30px 20px; + } +`; + +export const ImageWrapper = styled.div` + width: 100%; + background: white; + border: 1px solid #e2e8f0; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +`; + +export const ImageContainer = styled.div` + position: relative; + width: 100%; + background: white; + overflow: hidden; + user-select: none; + touch-action: none; + min-height: 200px; +`; + +export const CurriculumImage = styled.img` + width: 100%; + height: auto; + display: block; + object-fit: contain; + transform-origin: center; + will-change: transform; + user-select: none; + -webkit-user-drag: none; +`; + +export const ImageCaption = styled.div` + padding: 1rem; + text-align: center; + color: #4a5568; + font-size: 0.9rem; + border-top: 1px solid #e2e8f0; + background: #f8fafc; + + ${media.mobile} { + padding: 0.75rem; + font-size: 0.8rem; + } +`; + +export const DownloadSection = styled.div` + display: flex; + justify-content: center; + margin-top: 1rem; + + ${media.mobile} { + margin-top: 0.75rem; + } +`; + +export const DownloadLink = styled.a` + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + color: #2d3748; + text-decoration: none; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 0.9rem; + transition: all 0.2s ease-in-out; + background: white; + + &:hover { + background: #f8fafc; + border-color: #cbd5e0; + } + + svg { + width: 18px; + height: 18px; + } + + ${media.mobile} { + width: 100%; + justify-content: center; + font-size: 0.85rem; + } +`; +export const ZoomControls = styled.div` + position: absolute; + top: 1rem; + right: 1rem; + display: flex; + gap: 0.5rem; + background: white; + padding: 0.25rem; + border-radius: 6px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + z-index: 10; +`; + +export const ZoomButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: white; + color: #4a5568; + cursor: pointer; + transition: all 0.2s; + + &:hover:not(:disabled) { + background: #f8fafc; + border-color: #cbd5e0; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + svg { + width: 18px; + height: 18px; + } +`; + +export const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + min-height: 300px; + color: #4a5568; + font-size: 1.1rem; + + ${media.mobile} { + min-height: 200px; + font-size: 0.9rem; + } +`; + +export const ErrorContainer = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + margin: 2rem auto; + padding: 1rem; + max-width: 600px; + background-color: #fff5f5; + color: #c53030; + border-radius: 8px; + font-size: 1rem; + border: 1px solid #feb2b2; + text-align: center; + justify-content: center; + + svg { + flex-shrink: 0; + } + + ${media.mobile} { + margin: 1rem; + padding: 0.75rem; + font-size: 0.875rem; + gap: 0.375rem; + } +`; + +export const DragInstructions = styled.div` + position: absolute; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.7); + color: white; + padding: 0.5rem 1rem; + border-radius: 4px; + font-size: 0.85rem; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s; + + ${ImageContainer}:hover & { + opacity: 1; + } + + ${media.mobile} { + display: none; + } +`; + +export const TouchInstructions = styled.div` + display: none; + text-align: center; + color: #4a5568; + font-size: 0.85rem; + margin-top: 0.5rem; + + ${media.mobile} { + display: block; + } +`;