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 e966cb19..ba0fe034 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'; interface PageTransitionProps { @@ -193,6 +197,7 @@ function AppContent() { {/* graduate */} } /> + } /> {/* about */} } /> diff --git a/frontend/src/pages/Graduate/GraduateCurriculum.tsx b/frontend/src/pages/Graduate/GraduateCurriculum.tsx new file mode 100644 index 00000000..fc9bb5b1 --- /dev/null +++ b/frontend/src/pages/Graduate/GraduateCurriculum.tsx @@ -0,0 +1,359 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { ZoomIn, ZoomOut, Download } from 'lucide-react'; +import * as S from './GraduateCurriculumStyle'; + +interface Position { + x: number; + y: number; +} + +interface DragState { + isDragging: boolean; + startX: number; + startY: number; + scrollLeft: number; + scrollTop: number; + lastTouch?: { x: number; y: number }; +} + +function GraduateCurriculum() { + const [scaleCur, setScaleCur] = useState(0.8); + const [scaleRoad, setScaleRoad] = useState(0.8); + const [positionCur, setPositionCur] = useState({ 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; + } +`;