Skip to content

Commit cb2d599

Browse files
committed
feat: add canvas-based assignment builder with drag-and-drop
- Replace button-based reordering with @dnd-kit/react drag-and-drop - Add canvas module: block system (math-item, text, divider blocks) - Add component palette, canvas toolbar (1/2-col layout, number/points toggle) - Collapsible config panel for larger canvas workspace - Fix recommendation panel overflow covering UI
1 parent fe4b9dc commit cb2d599

13 files changed

Lines changed: 1011 additions & 204 deletions

File tree

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
},
1616
"dependencies": {
1717
"@auth/prisma-adapter": "^2.11.1",
18+
"@dnd-kit/helpers": "^0.3.2",
19+
"@dnd-kit/react": "^0.3.2",
1820
"@math-item-os/db": "workspace:*",
1921
"@math-item-os/math-parser": "workspace:*",
2022
"@math-item-os/shared": "workspace:*",

apps/web/src/app/(dashboard)/admin/assignments/new/page.tsx

Lines changed: 91 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@
33
import { useState, useCallback, useMemo } from "react";
44
import { useRouter } from "next/navigation";
55
import { toast } from "sonner";
6+
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
67

78
import { trpc } from "@/lib/trpc";
89
import { KatexRenderer } from "@/components/math/katex-renderer";
910
import { Button } from "@/components/ui/button";
1011
import { cn } from "@/lib/utils";
11-
import {
12-
AssignmentBuilder,
13-
type AssignmentBuilderItem,
14-
} from "@/components/admin/assignment-builder";
12+
import { CanvasBuilder } from "@/components/admin/canvas/canvas-builder";
13+
import { useCanvasState } from "@/components/admin/canvas/use-canvas-state";
1514

1615
// --- 상수 ---
1716

@@ -41,18 +40,6 @@ const PURPOSE_DIFFICULTY_GUIDE: Record<AssignmentPurpose, string> = {
4140
advanced: "고난이도 (4~5)",
4241
};
4342

44-
// --- 목적별 기본 난이도 범위 ---
45-
46-
const PURPOSE_DIFFICULTY_RANGE: Record<
47-
AssignmentPurpose,
48-
{ min: number; max: number }
49-
> = {
50-
diagnosis: { min: 1, max: 5 },
51-
remediation: { min: 1, max: 2 },
52-
pre_exam: { min: 3, max: 4 },
53-
advanced: { min: 4, max: 5 },
54-
};
55-
5643
// --- 메인 페이지 ---
5744

5845
export default function NewAssignmentPage() {
@@ -65,22 +52,19 @@ export default function NewAssignmentPage() {
6552
const [itemCount, setItemCount] = useState(DEFAULT_ITEM_COUNT);
6653
const [searchQuery, setSearchQuery] = useState("");
6754

68-
// 문항 상태
69-
const [selectedItems, setSelectedItems] = useState<AssignmentBuilderItem[]>(
70-
[],
71-
);
72-
7355
// 검색/추천 상태
7456
const [showSearch, setShowSearch] = useState(false);
7557
const [searchPage, setSearchPage] = useState(1);
7658
const [isRecommending, setIsRecommending] = useState(false);
59+
const [showConfig, setShowConfig] = useState(true);
60+
61+
// 캔버스 상태
62+
const canvas = useCanvasState();
7763

7864
// --- tRPC 쿼리 ---
7965

80-
// 추천 문항 (목적/난이도/스킬 기반 4-factor 점수 엔진)
8166
const recommendMutation = trpc.admin.recommendItems.useMutation();
8267

83-
// 수동 검색
8468
const searchInput = useMemo(
8569
() => ({
8670
query: searchQuery.trim() || undefined,
@@ -143,15 +127,24 @@ export default function NewAssignmentPage() {
143127
[],
144128
);
145129

130+
// 기존 아이템 ID 수집 (추천 시 제외용)
131+
const existingItemIds = useMemo(
132+
() =>
133+
canvas.blocks
134+
.filter((b) => b.type === "math_item" && b.itemId)
135+
.map((b) => b.itemId!),
136+
[canvas.blocks],
137+
);
138+
146139
const handleRecommend = useCallback(() => {
147140
setIsRecommending(true);
148141
recommendMutation.mutate({
149142
purpose,
150143
difficulty: targetDifficulty,
151144
count: itemCount,
152-
excludeItemIds: selectedItems.map((si) => si.item.id),
145+
excludeItemIds: existingItemIds,
153146
});
154-
}, [purpose, targetDifficulty, itemCount, selectedItems, recommendMutation]);
147+
}, [purpose, targetDifficulty, itemCount, existingItemIds, recommendMutation]);
155148

156149
const handleToggleSearch = useCallback(() => {
157150
setShowSearch((prev) => !prev);
@@ -168,60 +161,42 @@ export default function NewAssignmentPage() {
168161

169162
const handleAddItem = useCallback(
170163
(rawItem: SearchResultItemData) => {
171-
// 중복 방지
172-
const alreadyExists = selectedItems.some((si) => si.item.id === rawItem.id);
173-
if (alreadyExists) return;
174-
175-
const entry: AssignmentBuilderItem = {
176-
item: {
164+
canvas.addMathItemBlock(
165+
{
177166
id: rawItem.id,
178167
bodyLatex: rawItem.bodyLatex,
179168
itemType: rawItem.itemType,
180169
difficultyAuthor: rawItem.difficultyAuthor ?? null,
181170
},
182-
position: selectedItems.length + 1,
183-
points: 10,
184-
};
185-
186-
setSelectedItems((prev) => [...prev, entry]);
187-
},
188-
[selectedItems],
189-
);
190-
191-
const handleItemsChange = useCallback(
192-
(items: readonly AssignmentBuilderItem[]) => {
193-
setSelectedItems([...items]);
194-
},
195-
[],
196-
);
197-
198-
const handleRemoveItem = useCallback(
199-
(itemId: string) => {
200-
setSelectedItems((prev) => prev.filter((si) => si.item.id !== itemId));
171+
10,
172+
);
201173
},
202-
[],
174+
[canvas],
203175
);
204176

205177
const handleSave = useCallback(() => {
178+
const payload = canvas.toSavePayload();
206179
if (title.trim().length === 0) return;
207-
if (selectedItems.length === 0) return;
180+
if (payload.itemIds.length === 0) return;
208181

209182
createAssignmentMutation.mutate({
210183
title: title.trim(),
211184
purpose,
212-
itemIds: selectedItems.map((si) => si.item.id),
213-
points: selectedItems.map((si) => si.points),
185+
itemIds: payload.itemIds,
186+
points: payload.points,
214187
});
215-
}, [title, purpose, selectedItems, createAssignmentMutation]);
188+
}, [title, purpose, canvas, createAssignmentMutation]);
216189

217190
// --- 파생 상태 ---
218191

219192
const selectedItemIds = useMemo(
220-
() => new Set(selectedItems.map((si) => si.item.id)),
221-
[selectedItems],
193+
() => new Set(existingItemIds),
194+
[existingItemIds],
222195
);
223196

224-
const canSave = title.trim().length > 0 && selectedItems.length >= 1;
197+
const canSave =
198+
title.trim().length > 0 &&
199+
canvas.blocks.some((b) => b.type === "math_item");
225200

226201
const recommendResult = recommendMutation.data;
227202
const recommendedItems: SearchResultItemData[] = useMemo(
@@ -243,58 +218,73 @@ export default function NewAssignmentPage() {
243218
const searchTotal = searchQueryResult.data?.total ?? 0;
244219

245220
return (
246-
<div className="flex h-[calc(100vh-4rem)] flex-col gap-4 p-4">
221+
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3">
247222
{/* 페이지 헤더 */}
248-
<div>
249-
<h1 className="text-lg font-semibold text-slate-900">학습지 제작</h1>
250-
<p className="text-sm text-slate-500">
251-
목적에 맞는 문항을 추천받거나 직접 검색하여 학습지를 구성합니다
252-
</p>
223+
<div className="flex items-center justify-between">
224+
<div>
225+
<h1 className="text-lg font-semibold text-slate-900">학습지 제작</h1>
226+
<p className="text-sm text-slate-500">
227+
문항을 추천/검색하여 학습지를 구성합니다
228+
</p>
229+
</div>
230+
<Button
231+
variant="ghost"
232+
size="sm"
233+
onClick={() => setShowConfig((v) => !v)}
234+
className="gap-1.5 text-xs text-slate-500"
235+
>
236+
{showConfig ? (
237+
<><PanelLeftClose className="h-4 w-4" /> 설정 접기</>
238+
) : (
239+
<><PanelLeftOpen className="h-4 w-4" /> 설정 열기</>
240+
)}
241+
</Button>
253242
</div>
254243

255244
{/* 2-column 레이아웃 */}
256-
<div className="flex flex-1 gap-4 overflow-hidden">
257-
{/* 왼쪽 패널: 학습지 설정 */}
258-
<div className="w-[380px] shrink-0 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4">
259-
<ConfigurationPanel
260-
title={title}
261-
purpose={purpose}
262-
targetDifficulty={targetDifficulty}
263-
itemCount={itemCount}
264-
isRecommending={recommendMutation.isPending}
265-
onTitleChange={handleTitleChange}
266-
onPurposeChange={handlePurposeChange}
267-
onDifficultyChange={handleDifficultyChange}
268-
onItemCountChange={handleItemCountChange}
269-
onRecommend={handleRecommend}
270-
onToggleSearch={handleToggleSearch}
271-
showSearch={showSearch}
272-
/>
273-
</div>
245+
<div className="flex flex-1 gap-3 overflow-hidden">
246+
{/* 왼쪽 패널: 학습지 설정 (접기 가능) */}
247+
{showConfig && (
248+
<div className="w-[300px] shrink-0 overflow-y-auto rounded-lg border border-slate-200 bg-white p-3">
249+
<ConfigurationPanel
250+
title={title}
251+
purpose={purpose}
252+
targetDifficulty={targetDifficulty}
253+
itemCount={itemCount}
254+
isRecommending={recommendMutation.isPending}
255+
onTitleChange={handleTitleChange}
256+
onPurposeChange={handlePurposeChange}
257+
onDifficultyChange={handleDifficultyChange}
258+
onItemCountChange={handleItemCountChange}
259+
onRecommend={handleRecommend}
260+
onToggleSearch={handleToggleSearch}
261+
showSearch={showSearch}
262+
/>
263+
</div>
264+
)}
274265

275-
{/* 오른쪽 패널: 문항 구성 */}
276-
<div className="flex flex-1 flex-col gap-4 overflow-hidden">
277-
{/* 문항 빌더 */}
266+
{/* 캔버스 빌더 (최대 공간) */}
267+
<div className="flex flex-1 flex-col gap-3 overflow-hidden">
278268
<div className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4">
279-
{selectedItems.length === 0 ? (
280-
<div className="flex h-full items-center justify-center">
281-
<p className="text-sm text-slate-400">
282-
아래에서 추천 또는 검색된 문항을 추가하세요
283-
</p>
284-
</div>
285-
) : (
286-
<AssignmentBuilder
287-
items={selectedItems}
288-
onItemsChange={handleItemsChange}
289-
onRemoveItem={handleRemoveItem}
290-
/>
291-
)}
269+
<CanvasBuilder
270+
blocks={canvas.blocks}
271+
layout={canvas.layout}
272+
selectedBlockId={canvas.selectedBlockId}
273+
onAddBlock={canvas.addBlock}
274+
onRemoveBlock={canvas.removeBlock}
275+
onMoveBlock={canvas.moveBlock}
276+
onUpdateBlock={canvas.updateBlock}
277+
onSelectBlock={canvas.selectBlock}
278+
onLayoutChange={canvas.setLayout}
279+
/>
292280
</div>
293281

294282
{/* 저장 버튼 */}
295-
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-4 py-3">
283+
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-3 py-2">
296284
<p className="text-sm text-slate-500">
297-
선택된 문항: {selectedItems.length}
285+
문항:{" "}
286+
{canvas.blocks.filter((b) => b.type === "math_item").length}
287+
개 / 전체 블록: {canvas.blocks.length}
298288
</p>
299289
<Button
300290
disabled={!canSave || createAssignmentMutation.isPending}
@@ -315,7 +305,7 @@ export default function NewAssignmentPage() {
315305
</div>
316306

317307
{/* 하단 패널: 추천/검색 결과 */}
318-
<div className="shrink-0 overflow-hidden rounded-lg border border-slate-200 bg-white">
308+
<div className="max-h-[200px] shrink-0 overflow-y-auto rounded-lg border border-slate-200 bg-white">
319309
{/* 검색 입력 (토글) */}
320310
{showSearch && (
321311
<div className="border-b border-slate-200 p-3">

0 commit comments

Comments
 (0)