Skip to content

Commit 8f70729

Browse files
committed
feat: 드래그 앤 드롭으로 콘텐츠 추가 기능 추가
1 parent 90e3066 commit 8f70729

4 files changed

Lines changed: 185 additions & 94 deletions

File tree

apps/web/src/components/topic/content-list.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export default function ContentList({
4040
await createContent({
4141
topicId: id,
4242
contentId: content.id,
43+
posX: 0,
44+
posY: 0,
4345
});
4446
};
4547

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"use client";
2+
3+
import { forwardRef, useMemo } from "react";
4+
5+
import { useCreateContent } from "@/lib/tanstack/mutation/topic-content";
6+
import type { CategoryContentDTO } from "@/models/content";
7+
import {
8+
Background,
9+
Connection,
10+
Edge,
11+
Node,
12+
NodeProps,
13+
NodeTypes,
14+
type OnEdgesChange,
15+
type OnNodesChange,
16+
ReactFlow,
17+
useReactFlow,
18+
} from "@xyflow/react";
19+
20+
import { AlertTriangle, Lightbulb, Loader2, Plus } from "lucide-react";
21+
22+
import CustomNode from "./custom-node";
23+
import AddTopicDialog from "../(with-side-bar)/layout/add-topic-dialog";
24+
25+
interface FlowCanvasProps {
26+
isLoading: boolean;
27+
isTopicError: boolean;
28+
isNotFoundError: boolean;
29+
id: string;
30+
nodes: Node[];
31+
edges: Edge[];
32+
onNodesChange: OnNodesChange<Node>;
33+
onEdgesChange: OnEdgesChange<Edge>;
34+
onConnect: (params: Connection) => void;
35+
onEdgeClick: (e: React.MouseEvent, edge: Edge) => void;
36+
onDragOver: (e: React.DragEvent) => void;
37+
selectedNodeIds: string[];
38+
onNodeSelect: (nodeId: string) => void;
39+
}
40+
41+
const FlowCanvas = forwardRef<HTMLDivElement, FlowCanvasProps>(
42+
(
43+
{
44+
isLoading,
45+
isTopicError,
46+
isNotFoundError,
47+
id,
48+
nodes,
49+
edges,
50+
onNodesChange,
51+
onEdgesChange,
52+
onConnect,
53+
onEdgeClick,
54+
onDragOver,
55+
selectedNodeIds,
56+
onNodeSelect,
57+
},
58+
ref
59+
) => {
60+
const { screenToFlowPosition } = useReactFlow();
61+
const { mutateAsync: createContent } = useCreateContent(id);
62+
63+
const nodeTypes: NodeTypes = useMemo(
64+
() => ({
65+
custom: (props: NodeProps) => (
66+
<CustomNode
67+
{...props}
68+
topicId={id}
69+
isSelected={selectedNodeIds.includes(props.id)}
70+
onSelect={onNodeSelect}
71+
/>
72+
),
73+
}),
74+
[selectedNodeIds, id]
75+
);
76+
77+
const onDrop = async (e: React.DragEvent) => {
78+
e.preventDefault();
79+
80+
const contentData: CategoryContentDTO = JSON.parse(
81+
e.dataTransfer.getData("application/json")
82+
);
83+
84+
// ReactFlow 좌표계로 변환
85+
const flowPosition = screenToFlowPosition({
86+
x: e.clientX,
87+
y: e.clientY,
88+
});
89+
90+
// 서버에 콘텐츠 추가
91+
await createContent({
92+
topicId: id,
93+
contentId: contentData.id,
94+
posX: flowPosition.x,
95+
posY: flowPosition.y,
96+
});
97+
};
98+
99+
return (
100+
<div
101+
ref={ref}
102+
className="relative flex-1 overflow-hidden rounded-r-lg border border-l-0"
103+
onDragOver={onDragOver}
104+
onDrop={onDrop}
105+
>
106+
{isLoading ? (
107+
<div className="flex h-full flex-col items-center justify-center gap-2">
108+
<Loader2 className="text-muted-foreground size-16 animate-spin" />
109+
<p className="text-muted-foreground text-xl font-semibold">토픽을 불러오고 있어요</p>
110+
</div>
111+
) : isTopicError ? (
112+
<div className="flex h-full flex-col items-center justify-center gap-2">
113+
<AlertTriangle className="text-destructive size-16" />
114+
<p className="text-muted-foreground text-xl font-semibold">
115+
{isNotFoundError ? "토픽을 찾을 수 없어요." : "토픽을 불러오는데 실패했어요."}
116+
</p>
117+
</div>
118+
) : id ? (
119+
<ReactFlow
120+
nodes={nodes}
121+
edges={edges}
122+
onNodesChange={onNodesChange}
123+
onEdgesChange={onEdgesChange}
124+
onConnect={onConnect}
125+
onEdgeClick={onEdgeClick}
126+
nodeTypes={nodeTypes}
127+
connectionLineStyle={{
128+
stroke: "#b1b1b7",
129+
strokeWidth: 3,
130+
}}
131+
defaultEdgeOptions={{
132+
style: {
133+
strokeWidth: 3,
134+
},
135+
}}
136+
>
137+
<Background />
138+
</ReactFlow>
139+
) : (
140+
<div className="bg-background absolute inset-0 z-10 flex items-center justify-center">
141+
<div className="text-center">
142+
<Lightbulb size={64} className="text-muted-foreground mx-auto mb-6 opacity-50" />
143+
<h3 className="mb-4 text-xl font-semibold">선택된 토픽이 없습니다</h3>
144+
<p className="text-muted-foreground mb-6">토픽을 선택하거나 새 토픽을 생성해보세요</p>
145+
<AddTopicDialog>
146+
<Plus size={16} />새 토픽 생성
147+
</AddTopicDialog>
148+
</div>
149+
</div>
150+
)}
151+
</div>
152+
);
153+
}
154+
);
155+
156+
FlowCanvas.displayName = "FlowCanvas";
157+
158+
export default FlowCanvas;

apps/web/src/page/topic/index.tsx

Lines changed: 23 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,32 @@
11
"use client";
22

3-
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
3+
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
44

55
import AddTopicDialog from "@/components/(with-side-bar)/layout/add-topic-dialog";
66
import AddStickerDialog from "@/components/topic/add-sticker-dialog";
77
import ContentList from "@/components/topic/content-list";
8-
import CustomNode from "@/components/topic/custom-node";
98
import EditTopicSidebar from "@/components/topic/edit-sticker-sidebar";
9+
import FlowCanvas from "@/components/topic/flow-canvas";
1010
import RemoveContentButton from "@/components/topic/remove-content-button";
1111
import SummarizeDialog from "@/components/topic/summarize-dialog";
1212
import { Button } from "@/components/ui/button";
1313
import { Input } from "@/components/ui/input";
1414
import type { ContentTypeOptions } from "@/constants/content";
1515
import { useCreateConnection, useRemoveConnection } from "@/lib/tanstack/mutation/connection";
16-
import { useCreateContent } from "@/lib/tanstack/mutation/topic-content";
1716
import { useGetTopicById } from "@/lib/tanstack/query/topic";
1817
import { useMobileMenuStore } from "@/lib/zustand/mobile-menu-store";
19-
import type { CategoryContentDTO } from "@/models/content";
2018
import { infoToast } from "@/utils/toast";
2119
import {
2220
addEdge,
23-
Background,
2421
Connection,
2522
Edge,
2623
Node,
27-
NodeProps,
28-
NodeTypes,
29-
ReactFlow,
24+
ReactFlowProvider,
3025
useEdgesState,
3126
useNodesState,
3227
} from "@xyflow/react";
3328

34-
import { AlertTriangle, Lightbulb, Loader2, Menu, Plus, Search } from "lucide-react";
29+
import { Menu, Plus, Search } from "lucide-react";
3530

3631
interface TopicBoardPageProps {
3732
id: string;
@@ -40,24 +35,14 @@ interface TopicBoardPageProps {
4035

4136
const initialNodes: Node[] = [];
4237

43-
const connectionLineStyle = {
44-
stroke: "#b1b1b7",
45-
strokeWidth: 3,
46-
};
47-
48-
const defaultEdgeOptions = {
49-
style: {
50-
strokeWidth: 3,
51-
},
52-
};
53-
5438
export default function TopicBoardPage({ id, type }: TopicBoardPageProps) {
5539
const [searchQuery, setSearchQuery] = useState("");
5640
const [contentPanelWidth, setContentPanelWidth] = useState(300); // Content Panel 기본 너비
5741
const [isResizing, setIsResizing] = useState(false);
5842
const [selectedNodeIds, setSelectedNodeIds] = useState<string[]>([]);
5943

6044
const contentPanelRef = useRef<HTMLDivElement | null>(null);
45+
const reactFlowWrapper = useRef<HTMLDivElement | null>(null);
6146

6247
const { toggle } = useMobileMenuStore();
6348

@@ -70,7 +55,6 @@ export default function TopicBoardPage({ id, type }: TopicBoardPageProps) {
7055
} = useGetTopicById(id);
7156
const { mutateAsync: createConnection } = useCreateConnection();
7257
const { mutateAsync: removeConnection } = useRemoveConnection();
73-
const { mutateAsync: createContent } = useCreateContent(id);
7458

7559
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(initialNodes);
7660
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
@@ -82,19 +66,6 @@ export default function TopicBoardPage({ id, type }: TopicBoardPageProps) {
8266
};
8367

8468
const isNotFoundError = !isLoading && error?.message.includes("404");
85-
const nodeTypes: NodeTypes = useMemo(
86-
() => ({
87-
custom: (props: NodeProps) => (
88-
<CustomNode
89-
{...props}
90-
topicId={id}
91-
isSelected={selectedNodeIds.includes(props.id)}
92-
onSelect={onNodeSelect}
93-
/>
94-
),
95-
}),
96-
[selectedNodeIds, id]
97-
);
9869

9970
const onConnect = useCallback(
10071
async (params: Connection) => {
@@ -148,18 +119,6 @@ export default function TopicBoardPage({ id, type }: TopicBoardPageProps) {
148119
e.dataTransfer.dropEffect = "copy";
149120
};
150121

151-
const onDrop = async (e: React.DragEvent) => {
152-
e.preventDefault();
153-
154-
const contentData: CategoryContentDTO = JSON.parse(e.dataTransfer.getData("application/json"));
155-
156-
// 서버에 콘텐츠 추가
157-
await createContent({
158-
topicId: id,
159-
contentId: contentData.id,
160-
});
161-
};
162-
163122
const onSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
164123
setSearchQuery(event.target.value);
165124
};
@@ -283,52 +242,24 @@ export default function TopicBoardPage({ id, type }: TopicBoardPageProps) {
283242
</div>
284243

285244
{/* Canvas */}
286-
<div
287-
className="relative flex-1 overflow-hidden rounded-r-lg border border-l-0"
288-
onDragOver={onDragOver}
289-
onDrop={onDrop}
290-
>
291-
{isLoading ? (
292-
<div className="flex h-full flex-col items-center justify-center gap-2">
293-
<Loader2 className="text-muted-foreground size-16 animate-spin" />
294-
<p className="text-muted-foreground text-xl font-semibold">토픽을 불러오고 있어요</p>
295-
</div>
296-
) : isTopicError ? (
297-
<div className="flex h-full flex-col items-center justify-center gap-2">
298-
<AlertTriangle className="text-destructive size-16" />
299-
<p className="text-muted-foreground text-xl font-semibold">
300-
{isNotFoundError ? "토픽을 찾을 수 없어요." : "토픽을 불러오는데 실패했어요."}
301-
</p>
302-
</div>
303-
) : id ? (
304-
<ReactFlow
305-
nodes={nodes}
306-
edges={edges}
307-
onNodesChange={onNodesChange}
308-
onEdgesChange={onEdgesChange}
309-
onConnect={onConnect}
310-
onEdgeClick={onEdgeClick}
311-
nodeTypes={nodeTypes}
312-
connectionLineStyle={connectionLineStyle}
313-
defaultEdgeOptions={defaultEdgeOptions}
314-
>
315-
<Background />
316-
</ReactFlow>
317-
) : (
318-
<div className="bg-background absolute inset-0 z-10 flex items-center justify-center">
319-
<div className="text-center">
320-
<Lightbulb size={64} className="text-muted-foreground mx-auto mb-6 opacity-50" />
321-
<h3 className="mb-4 text-xl font-semibold">선택된 토픽이 없습니다</h3>
322-
<p className="text-muted-foreground mb-6">
323-
토픽을 선택하거나 새 토픽을 생성해보세요
324-
</p>
325-
<AddTopicDialog>
326-
<Plus size={16} />새 토픽 생성
327-
</AddTopicDialog>
328-
</div>
329-
</div>
330-
)}
331-
</div>
245+
<ReactFlowProvider>
246+
<FlowCanvas
247+
ref={reactFlowWrapper}
248+
isLoading={isLoading}
249+
isTopicError={isTopicError}
250+
isNotFoundError={isNotFoundError || false}
251+
id={id}
252+
nodes={nodes}
253+
edges={edges}
254+
onNodesChange={onNodesChange}
255+
onEdgesChange={onEdgesChange}
256+
onConnect={onConnect}
257+
onEdgeClick={onEdgeClick}
258+
onDragOver={onDragOver}
259+
selectedNodeIds={selectedNodeIds}
260+
onNodeSelect={onNodeSelect}
261+
/>
262+
</ReactFlowProvider>
332263
</div>
333264

334265
{/* Edit Topic Sidebar */}

apps/web/src/services/topic-content.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ export const updateContentSize = async (props: {
2525
export const createContent = async (props: {
2626
topicId: string;
2727
contentId: number;
28+
posX: number;
29+
posY: number;
2830
}): Promise<BaseResponseDTO<unknown>> => {
2931
const body = {
3032
...props,
31-
posX: 0,
32-
posY: 0,
3333
width: 350,
3434
height: 220,
3535
};

0 commit comments

Comments
 (0)