Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Deploy to GitHub Pages

on:
push:
branches:
- main
- master
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: "pages"
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install

- name: Build
run: pnpm run build

- name: Setup Pages
uses: actions/configure-pages@v4

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: './dist'

deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

3 changes: 3 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
packages:
- '.'

onlyBuiltDependencies:
- '@swc/core'
- msw
58 changes: 56 additions & 2 deletions src/ScheduleContext.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import React, { createContext, PropsWithChildren, useContext, useState } from "react";
import React, { createContext, PropsWithChildren, useCallback, useContext, useMemo, useRef, useState } from "react";
import { Schedule } from "./types.ts";
import dummyScheduleMap from "./dummyScheduleMap.ts";

interface ScheduleContextType {
tableIds: string[];
schedulesMap: Record<string, Schedule[]>;
setSchedulesMap: React.Dispatch<React.SetStateAction<Record<string, Schedule[]>>>;
updateTableSchedules: (tableId: string, updater: (schedules: Schedule[]) => Schedule[]) => void;
getSchedulesMapSize: () => number;
}

const ScheduleContext = createContext<ScheduleContextType | undefined>(undefined);
Expand All @@ -19,9 +22,60 @@ export const useScheduleContext = () => {

export const ScheduleProvider = ({ children }: PropsWithChildren) => {
const [schedulesMap, setSchedulesMap] = useState<Record<string, Schedule[]>>(dummyScheduleMap);

// schedulesMap의 최신 값을 ref로 유지하여 함수 참조를 안정적으로 유지
const schedulesMapRef = useRef(schedulesMap);
schedulesMapRef.current = schedulesMap;

// tableIds만 별도 state로 관리하여 변경 감지
const [tableIds, setTableIds] = useState(() => Object.keys(dummyScheduleMap));

// 특정 테이블만 업데이트하는 함수 - 메모이제이션으로 불필요한 리렌더링 방지
const updateTableSchedules = useCallback((tableId: string, updater: (schedules: Schedule[]) => Schedule[]) => {
setSchedulesMap(prev => {
const currentSchedules = prev[tableId];
if (!currentSchedules) return prev;

const newSchedules = updater(currentSchedules);

// 변경사항이 없으면 이전 객체 반환 (리렌더링 방지)
if (currentSchedules === newSchedules) return prev;

return {
...prev,
[tableId]: newSchedules
};
});
}, []);

// setSchedulesMap을 래핑하여 tableIds도 업데이트
const wrappedSetSchedulesMap = useCallback<React.Dispatch<React.SetStateAction<Record<string, Schedule[]>>>>((action) => {
setSchedulesMap(prev => {
const newMap = typeof action === 'function' ? action(prev) : action;
setTableIds(Object.keys(newMap));
return newMap;
});
}, []);

// schedulesMap의 크기를 가져오는 함수 (disabledRemoveButton 등에서 사용)
const getSchedulesMapSize = useCallback(() => {
return tableIds.length;
}, [tableIds]);

// Context value 메모이제이션
// schedulesMap을 포함하지만, updateTableSchedules가 특정 테이블만 업데이트하므로
// 각 테이블 컴포넌트는 React.memo로 메모이제이션되어 있어서
// schedules prop이 변경되지 않으면 리렌더링되지 않음
const value = useMemo(() => ({
tableIds,
schedulesMap,
setSchedulesMap: wrappedSetSchedulesMap,
updateTableSchedules,
getSchedulesMapSize,
}), [tableIds, schedulesMap, wrappedSetSchedulesMap, updateTableSchedules, getSchedulesMapSize]);

return (
<ScheduleContext.Provider value={{ schedulesMap, setSchedulesMap }}>
<ScheduleContext.Provider value={value}>
{children}
</ScheduleContext.Provider>
);
Expand Down
19 changes: 10 additions & 9 deletions src/ScheduleDndProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ function createSnapModifier(): Modifier {
const modifiers = [createSnapModifier()]

export default function ScheduleDndProvider({ children }: PropsWithChildren) {
const { schedulesMap, setSchedulesMap } = useScheduleContext();
const { schedulesMap, updateTableSchedules } = useScheduleContext();
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
distance: 0,
},
})
);
Expand All @@ -43,24 +43,25 @@ export default function ScheduleDndProvider({ children }: PropsWithChildren) {
const { active, delta } = event;
const { x, y } = delta;
const [tableId, index] = active.id.split(':');
const schedule = schedulesMap[tableId][index];
const schedules = schedulesMap[tableId] || [];
const schedule = schedules[Number(index)];
const nowDayIndex = DAY_LABELS.indexOf(schedule.day as typeof DAY_LABELS[number])
const moveDayIndex = Math.floor(x / 80);
const moveTimeIndex = Math.floor(y / 30);

setSchedulesMap({
...schedulesMap,
[tableId]: schedulesMap[tableId].map((targetSchedule, targetIndex) => {
// 변경된 테이블만 업데이트
updateTableSchedules(tableId, (currentSchedules) => {
return currentSchedules.map((targetSchedule, targetIndex) => {
if (targetIndex !== Number(index)) {
return { ...targetSchedule }
return targetSchedule; // 변경되지 않은 항목은 그대로 반환
}
return {
...targetSchedule,
day: DAY_LABELS[nowDayIndex + moveDayIndex],
range: targetSchedule.range.map(time => time + moveTimeIndex),
}
})
})
});
});
};

return (
Expand Down
76 changes: 54 additions & 22 deletions src/ScheduleTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { Schedule } from "./types.ts";
import { fill2, parseHnM } from "./utils.ts";
import { useDndContext, useDraggable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { ComponentProps, Fragment } from "react";
import { ComponentProps, Fragment, memo, useMemo, useCallback } from "react";

interface Props {
tableId: string;
Expand All @@ -38,25 +38,31 @@ const TIMES = [
.map((v) => `${parseHnM(v)}~${parseHnM(v + 50 * 분)}`),
] as const;

const ScheduleTable = ({ tableId, schedules, onScheduleTimeClick, onDeleteButtonClick }: Props) => {

const getColor = (lectureId: string): string => {
const ScheduleTable = memo(({ tableId, schedules, onScheduleTimeClick, onDeleteButtonClick }: Props) => {
// 색상 매핑 메모이제이션
const colorMap = useMemo(() => {
const lectures = [...new Set(schedules.map(({ lecture }) => lecture.id))];
const colors = ["#fdd", "#ffd", "#dff", "#ddf", "#fdf", "#dfd"];
return colors[lectures.indexOf(lectureId) % colors.length];
};
const map = new Map<string, string>();
lectures.forEach((lectureId, index) => {
map.set(lectureId, colors[index % colors.length]);
});
return map;
}, [schedules]);

const getColor = useCallback((lectureId: string): string => {
return colorMap.get(lectureId) || "#fdd";
}, [colorMap]);

const dndContext = useDndContext();

const getActiveTableId = () => {
const activeTableId = useMemo(() => {
const activeId = dndContext.active?.id;
if (activeId) {
return String(activeId).split(":")[0];
}
return null;
}

const activeTableId = getActiveTableId();
}, [dndContext.active?.id]);

return (
<Box
Expand Down Expand Up @@ -113,7 +119,7 @@ const ScheduleTable = ({ tableId, schedules, onScheduleTimeClick, onDeleteButton

{schedules.map((schedule, index) => (
<DraggableSchedule
key={`${schedule.lecture.title}-${index}`}
key={`${schedule.lecture.id}-${schedule.day}-${schedule.range[0]}-${index}`}
id={`${tableId}:${index}`}
data={schedule}
bg={getColor(schedule.lecture.id)}
Expand All @@ -125,9 +131,11 @@ const ScheduleTable = ({ tableId, schedules, onScheduleTimeClick, onDeleteButton
))}
</Box>
);
};
});

const DraggableSchedule = ({
ScheduleTable.displayName = 'ScheduleTable';

const DraggableSchedule = memo(({
id,
data,
bg,
Expand All @@ -137,25 +145,47 @@ const DraggableSchedule = ({
}) => {
const { day, range, room, lecture } = data;
const { attributes, setNodeRef, listeners, transform } = useDraggable({ id });
const leftIndex = DAY_LABELS.indexOf(day as typeof DAY_LABELS[number]);

const leftIndex = useMemo(() =>
DAY_LABELS.indexOf(day as typeof DAY_LABELS[number]),
[day]
);
const topIndex = range[0] - 1;
const size = range.length;

// 위치 계산 메모이제이션 - 드래그 중 불필요한 재계산 방지
const left = useMemo(() => `${120 + (CellSize.WIDTH * leftIndex) + 1}px`, [leftIndex]);
const top = useMemo(() => `${40 + (topIndex * CellSize.HEIGHT + 1)}px`, [topIndex]);
const width = useMemo(() => `${CellSize.WIDTH - 1}px`, []);
const height = useMemo(() => `${CellSize.HEIGHT * size - 1}px`, [size]);

// transform 계산 최적화 - 드래그 중 매번 계산하는 것을 방지
const transformValue = useMemo(() => {
if (!transform) return undefined;
return CSS.Translate.toString(transform);
}, [transform]);

return (
<Popover>
<Popover closeOnBlur={false}>
<PopoverTrigger>
<Box
position="absolute"
left={`${120 + (CellSize.WIDTH * leftIndex) + 1}px`}
top={`${40 + (topIndex * CellSize.HEIGHT + 1)}px`}
width={(CellSize.WIDTH - 1) + "px"}
height={(CellSize.HEIGHT * size - 1) + "px"}
left={left}
top={top}
width={width}
height={height}
bg={bg}
p={1}
boxSizing="border-box"
cursor="pointer"
cursor="grab"
ref={setNodeRef}
transform={CSS.Translate.toString(transform)}
transform={transformValue}
willChange="transform"
sx={{
'&:active': {
cursor: 'grabbing',
}
}}
{...listeners}
{...attributes}
>
Expand All @@ -175,6 +205,8 @@ const DraggableSchedule = ({
</PopoverContent>
</Popover>
);
}
});

DraggableSchedule.displayName = 'DraggableSchedule';

export default ScheduleTable;
Loading