Skip to content

Commit

Permalink
✨ 교과목 정보 페이지 추가 (#61)
Browse files Browse the repository at this point in the history
* ✨ 학사 및 교과 정렬 기준 추가

* ✨ 교과목 정렬 함수 추가

* 🚚  formatting함수들 각 컴포넌트로 이동

* ✨ 교과목 카드 추가

* 💄 내용 맞춰서 width 늘리기

* 💄 flip 구현

* 💄 스크롤 조정

* 💄 flip 수정

* 🐛 서브내비 이름 중복 버그 해결

* 🐛 헤더 스크롤 수정

* 🐛 스크롤시 전체 화면 움직이는 버그 해결

* 🐛 flip 공간 남는 버그 수정

* 💄 카드에 패딩 추가

* 🔥 불필요한 파일 삭제

* ✨ 대학원 교과목 정보 페이지 추가

* ♻️ 리뷰 반영

* ♻️ 리뷰 반영
  • Loading branch information
Limchansol authored Aug 24, 2023
1 parent 7b9d402 commit 19903fb
Show file tree
Hide file tree
Showing 21 changed files with 501 additions and 85 deletions.
35 changes: 31 additions & 4 deletions apis/academics.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,39 @@
import { graduateGuideData, undergraduateGuideData } from '@/data/academics';
import {
courseData1,
courseData2,
courseData3,
courseData4,
graduateGuideData,
undergraduateGuideData,
} from '@/data/academics';

import { Guide } from '@/types/academics';
import { Course, Guide } from '@/types/academics';

import { getRequest } from '.';

// export const getAcademicsGuide = (type: 'undergraduate' | 'graduate') =>
// getRequest(`/guide/${type}`) as Promise<Guide>;
type StudentType = 'undergraduate' | 'graduate';

// export const getAcademicsGuide = (type: StudentType) =>
// getRequest(`/academics/${type}/guide`) as Promise<Guide>;

export const getAcademicsGuide = async (type: 'undergraduate' | 'graduate') => {
return { description: type === 'undergraduate' ? undergraduateGuideData : graduateGuideData };
};

// export const getCourses = async (type: StudentType) =>
// getRequest(`/academics/${type}/courses`) as Promise<Course[]>;

export const getCourses = async (type: StudentType): Promise<Course[]> => [
...Array(10)
.fill(0)
.map((_, i) => ({ ...courseData1, id: i })),
...Array(10)
.fill(0)
.map((_, i) => ({ ...courseData2, id: i + 10 })),
...Array(10)
.fill(0)
.map((_, i) => ({ ...courseData3, id: i + 20 })),
...Array(10)
.fill(0)
.map((_, i) => ({ ...courseData4, id: i + 30 })),
];
36 changes: 36 additions & 0 deletions app/academics/graduate/courses/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { getCourses } from '@/apis/academics';

import CourseRow from '@/components/academics/CourseRow';
import PageLayout from '@/components/layout/pageLayout/PageLayout';

import { Course } from '@/types/academics';

export default async function GraduateCoursePage() {
const data = await getCourses('graduate');
console.log('Asdf');
const chunckedCourses = data ? chunkCourse(data) : [];

return (
<PageLayout titleType="big" titleMargin="mb-9">
{chunckedCourses.length > 0 && (
<div className="mt-6 flex flex-col gap-8">
{chunckedCourses.map((courses, i) => (
<CourseRow courses={courses} selectedOption="학년" key={i} />
))}
</div>
)}
</PageLayout>
);
}

const chunkCourse = (courses: Course[]) => {
const chunckedCourses: Course[][] = [];
const countPerLine = Math.floor(courses.length / 4);

chunckedCourses.push(courses.slice(0, countPerLine));
chunckedCourses.push(courses.slice(countPerLine, countPerLine * 2));
chunckedCourses.push(courses.slice(countPerLine * 2, countPerLine * 3));
chunckedCourses.push(courses.slice(countPerLine * 3));

return chunckedCourses;
};
83 changes: 83 additions & 0 deletions app/academics/undergraduate/courses/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
'use client';

import { Dispatch, SetStateAction, useState } from 'react';
import useSWR from 'swr';

import { getCourses } from '@/apis/academics';

import CourseRow from '@/components/academics/CourseRow';
import { Tag } from '@/components/common/Tags';
import PageLayout from '@/components/layout/pageLayout/PageLayout';

import { Classification, Course, SortOption } from '@/types/academics';

export default function UndergraduateCoursePage() {
const [selectedOption, setSelectedOption] = useState<SortOption>('학년');
const { data } = useSWR<Course[]>(`/academics/undergraduate/courses`, getCourses);
const sortedCourses = sortCourses(data ?? [], selectedOption);

return (
<PageLayout titleType="big" titleMargin="mb-9">
<SortOptions selectedOption={selectedOption} changeOption={setSelectedOption} />
{sortedCourses.length > 0 && (
<div className="mt-6 flex flex-col gap-8">
{sortedCourses.map((courses, i) => (
<CourseRow courses={courses} selectedOption={selectedOption} key={i} />
))}
</div>
)}
</PageLayout>
);
}

interface SortOptionsProps {
selectedOption: SortOption;
changeOption: Dispatch<SetStateAction<SortOption>>;
}

const SORT_OPTIONS: SortOption[] = ['학년', '교과목 구분', '학점'];

function SortOptions({ selectedOption, changeOption }: SortOptionsProps) {
return (
<div className="flex flex-wrap items-center gap-2.5">
{SORT_OPTIONS.map((option) =>
option === selectedOption ? (
<Tag key={option} tag={option} defaultStyle="fill" />
) : (
<Tag
key={option}
tag={option}
hoverStyle="fill"
defaultStyle="orange"
onClick={() => changeOption(option)}
/>
),
)}
</div>
);
}

const getSortGroupIndexByClassification = (classification: Classification) => {
if (classification === '전공필수') return 0;
else if (classification === '전공선택') return 1;
else return 2;
};

const sortCourses = (courses: Course[], sortOption: SortOption) => {
const sortedCourses: Course[][] = [];

if (sortOption === '학년') {
sortedCourses.push([], [], [], []);
courses.forEach((course) => sortedCourses[parseInt(course.year) - 1].push(course));
} else if (sortOption === '교과목 구분') {
sortedCourses.push([], [], []);
courses.forEach((course) =>
sortedCourses[getSortGroupIndexByClassification(course.classification)].push(course),
);
} else {
sortedCourses.push([], [], []);
courses.forEach((course) => sortedCourses[course.credit - 2].push(course));
}

return sortedCourses;
};
23 changes: 19 additions & 4 deletions app/community/notice/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { usePosts } from '@/hooks/usePosts';
import { notice } from '@/types/page';
import { NoticePostResponse } from '@/types/post';

import { formatDate } from '@/utils/formatting';
import { getPath } from '@/utils/page';

const writer = '박지혜';
Expand All @@ -31,9 +30,7 @@ export default function NoticePostPage() {
return (
<PageLayout title={currPost?.title ?? ''} titleType="small" titleMargin="mb-5">
<div className="mb-10 ml-2.5 text-xs font-yoon text-neutral-400">
글쓴이: {writer}, 작성시각:{' '}
{currPost &&
formatDate(new Date(currPost.createdAt), { includeDay: true, includeTime: true })}
글쓴이: {writer}, 작성시각: {currPost && formatFullDate(new Date(currPost.createdAt))}
</div>
<Attachment />
<HTMLViewer htmlContent={currPost?.description || ''} margin="mt-4 mb-10 ml-2.5" />
Expand All @@ -48,3 +45,21 @@ export default function NoticePostPage() {
</PageLayout>
);
}

const DAYS = ['일', '월', '화', '수', '목', '금', '토'];

const formatFullDate = (date: Date) => {
const yyyy = String(date.getFullYear()).padStart(4, '0');
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const day = DAYS[date.getDay()];

const hour24 = date.getHours();
const isAM = hour24 < 12;
const half = isAM ? '오전' : '오후';
const hour12 = [0, 12].includes(hour24) ? 12 : isAM ? hour24 : hour24 - 12;
const minute = `${date.getMinutes()}`.padStart(2, '0');
const time = `${half} ${hour12}:${minute}`;

return `${yyyy}/${mm}/${dd} (${day}) ${time}`; // e.g. 2023/08/01 (화) 오후 5:09
};
12 changes: 6 additions & 6 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import '@/styles/globals.css';
import { SWRProvider } from './swr-provider';

export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',
title: '서울대학교 컴퓨터공학부',
description: '서울대학교 컴퓨터공학부 홈페이지입니다.',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
Expand All @@ -20,12 +20,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<body className={`flex ${yoonGothic.variable} ${noto.variable} text-neutral-700 font-normal`}>
<NavbarContextProvider>
<Navbar />
<div className="overflow-auto flex flex-col flex-1">
<div className="flex flex-col flex-1">
<Header />
<div className="min-w-fit flex flex-col flex-1">
<main className="flex-1 pt-[9.25rem]">
<div className="min-w-fit flex flex-col flex-1 mt-[9.25rem] overflow-auto">
<main className="flex-1">
<SWRProvider>
<div className="flex-1 font-noto">{children}</div>
<div className="font-noto">{children}</div>
</SWRProvider>
</main>
<Footer />
Expand Down
2 changes: 1 addition & 1 deletion app/research/centers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export default function ResearchGroupsPage({ searchParams }: ResearchCentersPage
<ResearchCenterDetails center={selectedCenter} />
) : (
<p>
<b>id가 {`'${searchParams.selected}'`}</b> 연구센터는 존재하지 않습니다.
<b>{`'${searchParams.selected}'`}</b> 연구센터는 존재하지 않습니다.
</p>
)}
</PageLayout>
Expand Down
134 changes: 134 additions & 0 deletions components/academics/CourseCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { CSSProperties, useEffect, useReducer, useRef } from 'react';

import { Tag } from '@/components/common/Tags';

import { Course, SortOption } from '@/types/academics';

interface CourseCardProps {
course: Course;
selectedOption: SortOption;
}

const getSortedProperties = (course: Course, selectedOption: SortOption) => {
if (selectedOption === '교과목 구분') {
return [course.classification, course.year, `${course.credit}학점`];
} else if (selectedOption === '학점') {
return [`${course.credit}학점`, course.year, course.classification];
} else {
return [course.year, course.classification, `${course.credit}학점`];
}
};

const CARD_HEIGHT = 176; // px
const LINE_LIMIT = 6;
const TEXT_SIZE = 11; // px

export default function CourseCard({ course, selectedOption }: CourseCardProps) {
const sortedProperties = getSortedProperties(course, selectedOption);
const [isFlipped, flipCard] = useReducer((x) => !x, false);
const frontRef = useRef<HTMLDivElement>(null);
const backRef = useRef<HTMLDivElement>(null);

// resize card width
useEffect(() => {
const front = frontRef.current;
const back = backRef.current;
if (!front || !back) return;

if (isFlipped) {
if (back.scrollHeight > CARD_HEIGHT) {
const textCount = back.innerText.split('\n')[2].length;
const letterPerLine = textCount / LINE_LIMIT;
const expectedWidth = letterPerLine * TEXT_SIZE;
back.style.width = expectedWidth + 'px';
}
} else {
back.style.width = front.offsetWidth + 'px';
}
}, [isFlipped]);

const cardStyle: CSSProperties = {
position: 'relative',
paddingRight: '0.1875rem', // 3px
transformStyle: 'preserve-3d',
perspective: '1000px',
cursor: 'pointer',
};

const faceStyle: CSSProperties = {
top: 0,
left: 0,
height: '11rem', // 176px
borderRadius: '0.25rem', // 4px
boxShadow: '0 2px 4px 0 rgba(0,0,0,0.2)',
WebkitBackfaceVisibility: 'hidden',
backfaceVisibility: 'hidden',
transition: 'all ease-in-out 0.5s',
};

const frontStyle: CSSProperties = {
position: 'absolute',
padding: '1.125rem', // 18px
backgroundColor: 'white',
transform: isFlipped ? 'rotateY(-180deg)' : 'rotateY(0deg)',
};

const backStyle: CSSProperties = {
padding: '1.25rem 1.125rem', // 20px 18px
backgroundColor: '#f5f5f5', // bg-neutral-100
transform: isFlipped ? 'rotateY(0deg)' : 'rotateY(180deg)',
};

return (
<div className="card" style={cardStyle} onClick={flipCard}>
<div className="front" style={{ ...faceStyle, ...frontStyle }} ref={frontRef}>
<CardHeader sortedProperties={sortedProperties} />
<CardTitle name={course.name} code={course.code} />
<CardContentPreview description={course.description} />
</div>
<div className="back" style={{ ...faceStyle, ...backStyle }} ref={backRef}>
<CardTitle name={course.name} code={course.code} />
<CardContent description={course.description} />
</div>
</div>
);
}

function CardHeader({ sortedProperties }: { sortedProperties: string[] }) {
return (
<div className="flex items-center mb-4 justify-between">
<Tag tag={sortedProperties[0]} />
<span className="text-xs text-neutral-500">
<span className="mr-2">{sortedProperties[1]}</span>
<span>{sortedProperties[2]}</span>
</span>
</div>
);
}

function CardTitle({ name, code }: { name: string; code: string }) {
return (
<h2 className="mb-2 whitespace-nowrap">
<span className="font-bold text-base mr-2">{name}</span>
<span className="text-xs text-neutral-500">{code}</span>
</h2>
);
}

function CardContentPreview({ description }: { description: string }) {
return (
<div className="flex">
<p className="grow w-0 line-clamp-2 text-[0.6875rem] text-neutral-400 leading-normal">
{description}
</p>
</div>
);
}

function CardContent({ description }: { description: string }) {
return (
<div className="flex">
<p className="w-0 grow text-[0.6875rem] text-neutral-500">{description}</p>
</div>
);
}
Loading

0 comments on commit 19903fb

Please sign in to comment.