Skip to content
Merged
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
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"ci": "pnpm install --frozen-lockfile"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@tanstack/react-query": "^5.64.2",
"@tanstack/react-query-devtools": "^5.64.2",
"lottie-react": "^2.4.1",
Expand Down
37 changes: 37 additions & 0 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/src/components/Tooltip/Tooltip.styled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { styled } from 'styled-components';

export const TooltipLayout = styled.div`
position: absolute;
top: 0;
top: -8px;
left: 50%;
transform: translateX(-50%);

Expand Down
11 changes: 10 additions & 1 deletion frontend/src/pages/PlanPage/PlanPage.styled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,20 @@ import styled from 'styled-components';
export const PlanPageLayout = styled.section`
position: relative;
height: inherit;
display: flex;
flex-direction: column;
`;

export const StickyHeader = styled.div`
position: sticky;
`;

export const ScrollableContent = styled.div`
display: flex;
flex-direction: column;
gap: 2.4rem;
overflow-y: scroll;
padding-top: 2.4rem;
overflow-y: auto;
`;

export const HorizontalLine = styled.div`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import styled from 'styled-components';

export const StickyHeader = styled.div`
position: sticky;
`;
Comment on lines +3 to +5
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider consolidating duplicate StickyHeader components.

This StickyHeader component is identical to the one in frontend/src/pages/PlanPage/PlanPage.styled.ts (lines 10-12). Consider creating a shared styled component to avoid duplication and potential inconsistencies.

Consider moving the shared StickyHeader to a common styled components file:

// shared/styled/components.ts
export const StickyHeader = styled.div`
  position: sticky;
  top: 0;
  z-index: 10;
`;

Then import it in both files that need it.

🤖 Prompt for AI Agents
In frontend/src/pages/PlanPage/components/PlanHeader/PlanHeader.styled.ts lines
3 to 5, the StickyHeader styled component duplicates the one in
frontend/src/pages/PlanPage/PlanPage.styled.ts lines 10 to 12. To fix this,
create a shared styled component file (e.g., shared/styled/components.ts)
defining StickyHeader with the desired styles including position sticky, top 0,
and z-index 10. Then remove the duplicate definitions and import this shared
StickyHeader component in both PlanHeader.styled.ts and PlanPage.styled.ts to
avoid duplication and maintain consistency.

7 changes: 4 additions & 3 deletions frontend/src/pages/PlanPage/components/PlanHeader/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { useNavigate } from 'react-router';

import useCancelPlanMutation from '../../hooks/useCancelPlanMutation';
import usePlanInfoQuery from '../../hooks/usePlanInfoQuery';
import HomeModal from '../HomeModal';
import * as S from './PlanHeader.styled';

import Header from '@/components/Header';

Expand All @@ -27,7 +28,7 @@ const PlanHeader = () => {
};

return (
<>
<S.StickyHeader>
<Header>
<Header.Left>
<Header.HomeButton onClick={open} />
Expand All @@ -44,7 +45,7 @@ const PlanHeader = () => {
onConfirm={handleConfirm}
/>
)}
</>
</S.StickyHeader>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import styled from 'styled-components';

export const PlanContent = styled.div`
position: relative;
display: flex;
flex-direction: column;
gap: 2.4rem;
padding-bottom: 8rem;
`;

export const TimeBlockSection = styled.section`
display: flex;
flex-direction: column;
padding: 0 2.4rem;
position: relative;
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import styled from 'styled-components';

export const TimeBlockSection = styled.section`
display: flex;
flex-direction: column;
padding: 0 2.4rem;
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useDroppable } from '@dnd-kit/core';

import * as S from './TimeBlockSection.styled';

import { Path } from '@/api/plan';

interface TimeBlockSectionProps {
path: Path;
children: React.ReactNode;
}

const TimeBlockSection = ({ path, children }: TimeBlockSectionProps) => {
const { setNodeRef } = useDroppable({
id: path.pathId,
data: {
pathId: path.pathId,
},
});

return (
<S.TimeBlockSection key={path.pathId} ref={setNodeRef}>
{children}
</S.TimeBlockSection>
);
};

export default TimeBlockSection;
Original file line number Diff line number Diff line change
@@ -1,150 +1,76 @@
import { TouchEvent, useEffect, useRef, useState } from 'react';
import {
DndContext,
DragOverlay,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { useRef } from 'react';

import TimeBlockContent from '..';
import * as S from './PlanContent.styled';
import TimeBlockSection from './TimeBlockSection';
import TimeBlockHeader from '../../TimeBlockHeader';
import TimeBlockItem from '../TimeBlockList/TimeBlockItem';

import { Path, PathTodo } from '@/api/plan';
import { Path } from '@/api/plan';
import PlanTooltip from '@/components/PlanTooltip';
import { ERROR_MESSAGE } from '@/constants/message';
import useToast from '@/hooks/useToast';
import useUpdatePathTodoMutation from '@/pages/PlanPage/hooks/useUpdatePathTodoMutation';

const LONG_PRESS_DURATION = 300;

export interface DraggingTodo {
todo: PathTodo;
x: number;
y: number;
offsetX: number;
offsetY: number;
}
import { useDragAndDrop } from '@/pages/PlanPage/hooks/useDragAndDrop';

interface PlanContentProps {
paths?: Path[];
}

const PlanContent = ({ paths }: PlanContentProps) => {
const [draggingTodo, setDraggingTodo] = useState<DraggingTodo | null>(null);
const timeBlockRefs = useRef(new Map<number, HTMLElement>());
const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null);

const { mutateAsync: updatePathTodo } = useUpdatePathTodoMutation();
const { toast } = useToast();

const handleTouchStart = (e: TouchEvent<HTMLElement>, todo: PathTodo) => {
const touch = e.touches[0];
const targetElement = e.currentTarget;

// 터치한 위치와 block의 위치 차이 계산하여 보정
const offsetX = touch.pageX - targetElement.offsetLeft;
const offsetY = touch.pageY - targetElement.offsetTop;

longPressTimeoutRef.current = setTimeout(() => {
setDraggingTodo({
todo,
x: touch.pageX - offsetX,
y: touch.pageY - offsetY,
offsetX,
offsetY,
});
}, LONG_PRESS_DURATION);
};

const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
if (longPressTimeoutRef.current) {
clearTimeout(longPressTimeoutRef.current); // 스크롤 시 터치 취소
longPressTimeoutRef.current = null;
}

if (!draggingTodo) return;

const touch = e.touches[0];

setDraggingTodo((prev) =>
prev
? {
...prev,
x: touch.pageX - prev.offsetX,
y: touch.pageY - prev.offsetY,
}
: null,
);
};

const handleTouchEnd = async (e: React.TouchEvent<HTMLElement>) => {
e.stopPropagation();

if (longPressTimeoutRef.current) {
clearTimeout(longPressTimeoutRef.current);
longPressTimeoutRef.current = null;
}

if (!draggingTodo) return;

e.preventDefault();

const touch = e.changedTouches[0];

for (const [pathId, timeBlockRef] of timeBlockRefs.current) {
const rect = timeBlockRef.getBoundingClientRect();
const isValidTargetBlock = touch.clientY >= rect.top && touch.clientY <= rect.bottom;

if (isValidTargetBlock) {
await updatePathTodo(
{ todoId: draggingTodo.todo.todoId, newPathId: pathId },
{
onError: () => {
toast({ message: ERROR_MESSAGE.UPDATE_PATH_TODO });
},
},
);

break;
}
}

setDraggingTodo(null);
};

useEffect(() => {
const handleGlobalTouchEnd = (e: globalThis.TouchEvent) => {
if (!draggingTodo) return;

e.preventDefault();
};

document.addEventListener('touchend', handleGlobalTouchEnd);

return () => document.removeEventListener('touchend', handleGlobalTouchEnd);
}, [draggingTodo]);
const containerRef = useRef<HTMLDivElement | null>(null);
const { draggingTodo, hoveredPathId, handleDragStart, handleDragEnd, handleDragOver } =
useDragAndDrop({ paths, containerRef });

const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
delay: 300,
},
}),
);

return (
<S.PlanContent>
{paths?.map((path, idx) => (
<S.TimeBlockSection
key={path.pathId}
ref={(node) => {
if (node && timeBlockRefs.current.get(path.pathId) !== node) {
timeBlockRefs.current.set(path.pathId, node);
} else if (node === null) {
timeBlockRefs.current.delete(path.pathId);
}
}}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
{idx === 0 && <PlanTooltip />}
<TimeBlockHeader path={path} />
<TimeBlockContent
path={path}
draggingTodo={draggingTodo}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
/>
</S.TimeBlockSection>
))}
</S.PlanContent>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
>
<S.PlanContent ref={containerRef}>
<PlanTooltip />
{paths?.map((path) => {
const shouldShowPreview = hoveredPathId === path.pathId && draggingTodo;
const todosWithPreview = shouldShowPreview
? [...path.todos, { ...draggingTodo }]
: path.todos;

return (
<TimeBlockSection key={path.pathId} path={path}>
<TimeBlockHeader path={path} />
<TimeBlockContent
path={{ ...path, todos: todosWithPreview }}
draggingTodo={draggingTodo}
/>
</TimeBlockSection>
);
})}
</S.PlanContent>

{/* 드래그 오버레이 */}
{draggingTodo && (
<DragOverlay dropAnimation={null}>
<TimeBlockItem todo={draggingTodo} draggingTodo={draggingTodo} isDragOverlay={true} />
</DragOverlay>
)}
</DndContext>
);
};

Expand Down
Loading