Skip to content

Commit

Permalink
Merge pull request #242 from urinaner/feature/241
Browse files Browse the repository at this point in the history
[FE] 대학원 커리큘럼 페이지 구현
  • Loading branch information
pillow12360 authored Jan 3, 2025
2 parents 56618e3 + e8d8b17 commit 6ed2422
Show file tree
Hide file tree
Showing 5 changed files with 592 additions and 0 deletions.
Binary file added frontend/public/graduate-curriculum1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/graduate-curriculum2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions frontend/src/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -193,6 +197,7 @@ function AppContent() {

{/* graduate */}
<Route path="graduate/overview" element={<GraduateOverview />} />
<Route path="graduate/curriculum" element={<GraduateCurriculum />} />

{/* about */}
<Route path="/about" element={<Overview />} />
Expand Down
359 changes: 359 additions & 0 deletions frontend/src/pages/Graduate/GraduateCurriculum.tsx
Original file line number Diff line number Diff line change
@@ -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<Position>({ x: 0, y: 0 });
const [positionRoad, setPositionRoad] = useState<Position>({ x: 0, y: 0 });

const dragStateCur = useRef<DragState>({
isDragging: false,
startX: 0,
startY: 0,
scrollLeft: 0,
scrollTop: 0,
});

const dragStateRoad = useRef<DragState>({
isDragging: false,
startX: 0,
startY: 0,
scrollLeft: 0,
scrollTop: 0,
});

const curContainerRef = useRef<HTMLDivElement>(null);
const roadContainerRef = useRef<HTMLDivElement>(null);

const MIN_SCALE = 0.5;
const MAX_SCALE = 2;
const SCALE_STEP = 0.2;

const handleMouseDown = useCallback(
(
e: React.MouseEvent,
dragState: React.RefObject<DragState>,
containerRef: React.RefObject<HTMLDivElement>,
) => {
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<DragState>,
containerRef: React.RefObject<HTMLDivElement>,
setPosition: React.Dispatch<React.SetStateAction<Position>>,
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<DragState>,
containerRef: React.RefObject<HTMLDivElement>,
) => {
if (!dragState.current || !containerRef.current) return;

dragState.current.isDragging = false;
containerRef.current.style.cursor = 'grab';
},
[],
);

const handleTouchStart = useCallback(
(e: React.TouchEvent, dragState: React.RefObject<DragState>) => {
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<DragState>,
setPosition: React.Dispatch<React.SetStateAction<Position>>,
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<DragState>) => {
if (!dragState.current) return;
dragState.current.isDragging = false;
dragState.current.lastTouch = undefined;
},
[],
);

const handleZoom = useCallback(
(
type: 'in' | 'out',
currentScale: number,
setScale: React.Dispatch<React.SetStateAction<number>>,
setPosition: React.Dispatch<React.SetStateAction<Position>>,
) => {
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<DragState>;
containerRef: React.RefObject<HTMLDivElement>;
setPosition: React.Dispatch<React.SetStateAction<Position>>;
},
) => (
<S.ImageContainer
key={type}
ref={containerRef}
onMouseDown={(e: React.MouseEvent) =>
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' }}
>
<S.CurriculumImage
src={src}
alt={alt}
style={{
transform: `scale(${scale}) translate(${position.x}px, ${position.y}px)`,
transition: dragState.current?.isDragging
? 'none'
: 'transform 0.3s ease',
pointerEvents: scale > MIN_SCALE ? 'none' : 'auto',
}}
onError={(e) => {
e.currentTarget.src = src;
e.currentTarget.onerror = null;
}}
draggable="false"
/>

<S.ZoomControls>
<S.ZoomButton
onClick={() =>
handleZoom(
'out',
scale,
type === 'curriculum' ? setScaleCur : setScaleRoad,
setPosition,
)
}
disabled={scale <= MIN_SCALE}
aria-label={`${type} 축소`}
>
<ZoomOut />
</S.ZoomButton>
<S.ZoomButton
onClick={() =>
handleZoom(
'in',
scale,
type === 'curriculum' ? setScaleCur : setScaleRoad,
setPosition,
)
}
disabled={scale >= MAX_SCALE}
aria-label={`${type} 확대`}
>
<ZoomIn />
</S.ZoomButton>
</S.ZoomControls>
</S.ImageContainer>
),
[
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
],
);

return (
<S.Container>
<S.ImageWrapper>
{renderImage('curriculum', {
src: '/graduate-curriculum1.png',
alt: '대학원 커리큘럼',
scale: scaleCur,
position: positionCur,
dragState: dragStateCur,
containerRef: curContainerRef,
setPosition: setPositionCur,
})}
<S.ImageCaption>바이오융합공학 교과과정표1</S.ImageCaption>
</S.ImageWrapper>

<S.DownloadSection>
<S.DownloadLink
href="/graduate-curriculum1.png"
download="세종대학교_바이오융합공학_교과과정표1.png"
target="_blank"
rel="noopener noreferrer"
>
<Download size={18} />
교과과정표1 다운로드
</S.DownloadLink>
</S.DownloadSection>

<S.ImageWrapper>
{renderImage('roadmap', {
src: '/graduate-curriculum2.png',
alt: '대학원 커리큘럼',
scale: scaleRoad,
position: positionRoad,
dragState: dragStateRoad,
containerRef: roadContainerRef,
setPosition: setPositionRoad,
})}
<S.ImageCaption>바이오융합공학 교과과정표2</S.ImageCaption>
</S.ImageWrapper>

<S.DownloadSection>
<S.DownloadLink
href="/graduate-curriculum2.png"
download="세종대학교_바이오융합공학_교과과정표2.png"
target="_blank"
rel="noopener noreferrer"
>
<Download size={18} />
교과과정표2 다운로드
</S.DownloadLink>
</S.DownloadSection>
</S.Container>
);
}

export default GraduateCurriculum;
Loading

0 comments on commit 6ed2422

Please sign in to comment.