@leviosa-ai/detail-page-editor 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -105,8 +105,14 @@ export interface DetailPageHostApi {
105
105
  },
106
106
  signal?: AbortSignal,
107
107
  ) => Promise<DetailPagePromptEditResult>;
108
+ /**
109
+ * 고른 그림 한 장을 프롬프트 방향으로 다시 만든다.
110
+ *
111
+ * 문서 id 는 선택이다 — 이 일은 문서를 읽지도 고치지도 않고, 그림과 지시가 전부
112
+ * 인자로 간다. 캐러셀처럼 가리킬 문서가 없는 편집기도 같은 길을 쓴다.
113
+ */
108
114
  promptEditDetailPageImage: (
109
- generatedId: string,
115
+ generatedId: string | null,
110
116
  payload: {
111
117
  slot_role?: string;
112
118
  current_image_url?: string;
@@ -298,20 +304,6 @@ export interface BrandAsset {
298
304
  deleted_at: string | null;
299
305
  }
300
306
 
301
- /**
302
- * 무드보드와 브랜드킷은 **편집기가 안을 안 본다**. 불러서 킷으로 바꾸고 브랜드킷
303
- * 슬롯(호스트가 꽂는 노드)에 그대로 넘길 뿐이다.
304
- *
305
- * 그래서 모양을 선언하지 않는다. 선언하면 오히려 깨진다 — `deriveBrandKit` 의 인자는
306
- * 반변이라, 우리가 `unknown` 으로 적으면 앱이 자기 `Moodboard` 를 받는 함수를 못 꽂고,
307
- * 좁게 적으면 이번엔 우리 호출부가 못 넘긴다. 나르기만 하는 값에 양방향으로 열린 타입을
308
- * 주는 것이 정직하다.
309
- */
310
- /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
311
- export type BrandMoodboard = any;
312
- /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
313
- export type BrandKit = any;
314
-
315
307
  export interface DetailPageHostBrand {
316
308
  listBrandAssets: (
317
309
  brandId: string,
@@ -348,12 +340,6 @@ export interface DetailPageHostBrand {
348
340
  /** 훅을 못 쓰는 자리(모듈 함수)에서 활성 브랜드를 알아낼 때. */
349
341
  getStoredActiveBrandId: () => string | null;
350
342
 
351
- loadBrandMoodboard: (
352
- brand: BrandWorkspaceBrand,
353
- assets: BrandAsset[],
354
- signal?: AbortSignal,
355
- ) => Promise<BrandMoodboard>;
356
- deriveBrandKit: (moodboard: BrandMoodboard) => BrandKit;
357
343
  useBrandPrimaryColor: () => string;
358
344
  }
359
345
 
@@ -409,7 +395,6 @@ export interface DetailPageHostQueryKeys {
409
395
  brandId: string | null,
410
396
  media?: BrandAssetMedia,
411
397
  ) => readonly unknown[];
412
- brandMoodboard: (brandId: string | null) => readonly unknown[];
413
398
  detailPageShapeLibrary: () => readonly unknown[];
414
399
  detailPageStockPhotos: (query: string) => readonly unknown[];
415
400
  detailPageIcons: (
@@ -436,11 +421,6 @@ export interface DetailPageHostSlots {
436
421
  open: boolean;
437
422
  onOpenChange: (open: boolean) => void;
438
423
  }>;
439
- BrandKitPanel?: ComponentType<{
440
- kit: BrandKit;
441
- store: never;
442
- className?: string;
443
- }>;
444
424
  /**
445
425
  * '내 이미지' 서랍의 두 번째 탭 — 저작이 구운 사진.
446
426
  *
@@ -3,81 +3,23 @@
3
3
  import { useSyncExternalStore } from "react";
4
4
  import { observer } from "./canvas-observer";
5
5
  import { useTranslation } from "react-i18next";
6
- import {
7
- Sparkles,
8
- ChevronUp,
9
- ChevronDown,
10
- Copy,
11
- Plus,
12
- Trash2,
13
- Wand2,
14
- } from "lucide-react";
6
+ import { Wand2 } from "lucide-react";
15
7
 
16
8
  import {
17
9
  isSectionReauthorWired,
18
10
  requestSectionReauthor,
19
11
  subscribeSectionReauthorAvailability,
20
12
  } from "../../lib/detail-page/section-reauthor-bus";
21
- import { detailPageEditorProfile } from "../../lib/detail-page/editor-profile";
22
13
 
23
14
  /**
24
- * hookable-style per-section (per-page) floating quick toolbar.
15
+ * 활성 화면 옆에 뜨는 동그란 버튼 하나 — «이 화면 다시 만들기».
25
16
  *
26
- * Renders a vertical pill next to the active page with: AI, move up, move down,
27
- * duplicate, add, delete. The actions mirror the stock editor's stock ``<PageControls>``
28
- * (`page.setZIndex` / `page.clone` / `store.addPage` / `store.deletePages`) so
29
- * behaviour matches the SDK exactly — we just restyle it as a vertical rail and
30
- * disable the stock controls in the workspace.
17
+ * 예전에는 세로 띠에 AI·위·아래·복제·추가·삭제가 줄줄이 붙어 있었다. 화면을 가리는
18
+ * 데 비해 쓰는 것은 하나뿐이었고, 나머지 넷은 페이지를 다루는 일이라 페이지 목록과
19
+ * 오른쪽 페이지 패널로 옮겼다.
31
20
  */
32
21
 
33
- type PageLike = {
34
- id: string;
35
- bleed?: number;
36
- width?: number | string;
37
- height?: number | string;
38
- setZIndex: (index: number) => void;
39
- clone: () => void;
40
- };
41
- type StoreLike = {
42
- pages: PageLike[];
43
- activePage?: { id: string; bleed?: number; width?: number | string; height?: number | string };
44
- addPage: (props: Record<string, unknown>) => PageLike;
45
- deletePages: (ids: string[]) => void;
46
- openSidePanel?: (name: string) => void;
47
- };
48
-
49
- function IconButton({
50
- label,
51
- onClick,
52
- disabled,
53
- children,
54
- }: {
55
- label: string;
56
- onClick: () => void;
57
- disabled?: boolean;
58
- children: React.ReactNode;
59
- }) {
60
- return (
61
- <button
62
- type="button"
63
- title={label}
64
- aria-label={label}
65
- disabled={disabled}
66
- onClick={(e) => {
67
- e.stopPropagation();
68
- onClick();
69
- }}
70
- className={[
71
- "flex h-8 w-8 items-center justify-center rounded-dpe-lg transition-colors",
72
- disabled
73
- ? "cursor-not-allowed text-dpe-ink-300"
74
- : "text-dpe-ink-500 hover:bg-dpe-ink-100 hover:text-dpe-ink-900",
75
- ].join(" ")}
76
- >
77
- {children}
78
- </button>
79
- );
80
- }
22
+ type PageLike = { id: string };
81
23
 
82
24
  export const DetailPagePageToolbar = observer(function DetailPagePageToolbar({
83
25
  store,
@@ -93,96 +35,29 @@ export const DetailPagePageToolbar = observer(function DetailPagePageToolbar({
93
35
  isSectionReauthorWired,
94
36
  () => false,
95
37
  );
96
- const s = store as StoreLike;
97
38
  const p = page as PageLike;
98
- const profile = detailPageEditorProfile();
99
- const index = s.pages.findIndex((pg) => pg.id === p.id);
100
- const isFirst = index <= 0;
101
- const isLast = index === s.pages.length - 1;
102
- const canDelete = s.pages.length > 1;
103
- const canAdd = s.pages.length < profile.maxPages;
104
39
 
105
- const addAfter = () => {
106
- if (!canAdd) return;
107
- const ref = s.activePage ?? s.pages[index];
108
- const next = s.addPage({
109
- bleed: ref?.bleed ?? 0,
110
- width:
111
- profile.page.width === "document"
112
- ? ref?.width ?? "auto"
113
- : profile.page.width,
114
- height: profile.page.fixed
115
- ? profile.page.height
116
- : ref?.height ?? profile.page.height,
117
- });
118
- next.setZIndex(index + 1);
119
- };
40
+ // 다시 만들기를 못 꽂은 화면에서는 띠 자체가 없다 — 버튼이 하나뿐이라 그것이
41
+ // 빠지면 빈 껍데기만 남는다.
42
+ if (!reauthorWired) return null;
120
43
 
121
44
  return (
122
- <div className="flex flex-col items-center gap-0.5 rounded-2xl border border-dpe-ink-200 bg-dpe-surface/95 px-1 py-1.5 shadow-md backdrop-blur-sm">
123
- <button
124
- type="button"
125
- title={t("detailPage.pageToolbar.aiGenerate")}
126
- aria-label={t("detailPage.pageToolbar.aiGenerate")}
127
- onClick={(e) => {
128
- e.stopPropagation();
129
- s.openSidePanel?.("ai-generate");
130
- }}
131
- className="flex h-8 w-8 items-center justify-center rounded-dpe-lg transition-colors hover:bg-dpe-ink-100"
132
- >
133
- <Sparkles size={17} className="text-dpe-ai" />
134
- </button>
135
-
136
- {/* 이 화면만 마크업째 다시 만든다 — 슬롯 카피 편집과 달리 칸 수·표 같은 구조가
137
- 바뀔 수 있다. 요청만 띄우고 실제 일(모달·API·페이지 교체)은 편집기가 한다. */}
138
- {reauthorWired ? (
139
- <button
140
- type="button"
141
- title={t("detailPage.pageToolbar.reauthor", {
142
- defaultValue: "이 화면 다시 만들기",
143
- })}
144
- aria-label={t("detailPage.pageToolbar.reauthor", {
145
- defaultValue: "이 화면 다시 만들기",
146
- })}
147
- onClick={(e) => {
148
- e.stopPropagation();
149
- requestSectionReauthor(p.id);
150
- }}
151
- className="flex h-8 w-8 items-center justify-center rounded-dpe-lg transition-colors hover:bg-dpe-ink-100"
152
- >
153
- <Wand2 size={16} className="text-dpe-ai-alt" />
154
- </button>
155
- ) : null}
156
-
157
- <hr className="my-0.5 w-5 border-dpe-ink-200" />
158
-
159
- <IconButton label={t("detailPage.pageToolbar.moveUp")} onClick={() => p.setZIndex(index - 1)} disabled={isFirst}>
160
- <ChevronUp size={17} />
161
- </IconButton>
162
- <IconButton label={t("detailPage.pageToolbar.moveDown")} onClick={() => p.setZIndex(index + 1)} disabled={isLast}>
163
- <ChevronDown size={17} />
164
- </IconButton>
165
- <IconButton
166
- label={t("detailPage.pageToolbar.duplicate")}
167
- onClick={() => p.clone()}
168
- disabled={!canAdd}
169
- >
170
- <Copy size={16} />
171
- </IconButton>
172
- <IconButton
173
- label={t("detailPage.pageToolbar.addBelow")}
174
- onClick={addAfter}
175
- disabled={!canAdd}
176
- >
177
- <Plus size={17} />
178
- </IconButton>
179
-
180
- <hr className="my-0.5 w-5 border-dpe-ink-200" />
181
-
182
- <IconButton label={t("detailPage.pageToolbar.delete")} onClick={() => s.deletePages([p.id])} disabled={!canDelete}>
183
- <Trash2 size={16} />
184
- </IconButton>
185
- </div>
45
+ <button
46
+ type="button"
47
+ title={t("detailPage.pageToolbar.reauthor", {
48
+ defaultValue: "이 화면 다시 만들기",
49
+ })}
50
+ aria-label={t("detailPage.pageToolbar.reauthor", {
51
+ defaultValue: "이 화면 다시 만들기",
52
+ })}
53
+ onClick={(e) => {
54
+ e.stopPropagation();
55
+ requestSectionReauthor(p.id);
56
+ }}
57
+ className="flex h-10 w-10 items-center justify-center rounded-full border border-dpe-ink-200 bg-dpe-surface/95 shadow-md backdrop-blur-sm transition-colors hover:bg-dpe-ink-100"
58
+ >
59
+ <Wand2 size={17} className="text-dpe-ai-alt" />
60
+ </button>
186
61
  );
187
62
  });
188
63
  DetailPagePageToolbar.displayName = "DetailPagePageToolbar";
@@ -2,10 +2,10 @@
2
2
 
3
3
  import { detailPageEditorProfile } from "../../lib/detail-page/editor-profile";
4
4
 
5
- import { useCallback, useState, useSyncExternalStore } from "react";
5
+ import { Fragment, useCallback, useSyncExternalStore } from "react";
6
6
  import { observer } from "./canvas-observer";
7
7
  import { useTranslation } from "react-i18next";
8
- import { GripVertical } from "lucide-react";
8
+ import { Copy, GripVertical, Plus, Trash2 } from "lucide-react";
9
9
  import {
10
10
  DndContext,
11
11
  closestCenter,
@@ -23,7 +23,6 @@ import {
23
23
  import { CSS } from "@dnd-kit/utilities";
24
24
 
25
25
  import { detailPageThumbnailBus } from "./detail-page-thumbnail-bus";
26
- import { FillControl } from "./fill-control";
27
26
 
28
27
  /**
29
28
  * Custom Canvas side-panel "페이지" section (hookable-style page list).
@@ -53,12 +52,16 @@ type PageLike = {
53
52
  /** 단색 hex 또는 `linear-gradient(...)` 문자열. `FillControl`이 둘 다 만든다. */
54
53
  background?: string;
55
54
  set?: (props: Record<string, unknown>) => void;
55
+ clone?: () => void;
56
+ /** 문서 안 몇 번째인가를 바꾼다. 페이지 자신이 가진 함수다 — 스토어에는 없다. */
57
+ setZIndex?: (index: number) => void;
56
58
  };
57
59
  type StoreLike = {
58
60
  pages: PageLike[];
59
61
  activePage?: PageLike;
60
62
  selectPage: (id: string) => void;
61
- setPageZIndex: (id: string, index: number) => void;
63
+ addPage: (props: Record<string, unknown>) => PageLike;
64
+ deletePages?: (ids: string[]) => void;
62
65
  };
63
66
 
64
67
  type TFn = (key: string, opts?: Record<string, unknown>) => string;
@@ -94,6 +97,54 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({
94
97
  x: 0,
95
98
  });
96
99
 
100
+ /** 두 화면 사이에 새 화면을 끼우는 자리. 평소엔 선 한 줄, 올리면 «+» 가 뜬다. */
101
+ function InsertHere({
102
+ store,
103
+ after,
104
+ disabled,
105
+ }: {
106
+ store: StoreLike;
107
+ after: number;
108
+ disabled?: boolean;
109
+ }) {
110
+ const { t } = useTranslation("branding");
111
+ if (disabled) return null;
112
+
113
+ const insert = () => {
114
+ const ref = store.pages[after];
115
+ const profile = detailPageEditorProfile();
116
+ const next = store.addPage({
117
+ width:
118
+ profile.page.width === "document"
119
+ ? (ref?.computedWidth ?? "auto")
120
+ : profile.page.width,
121
+ height: profile.page.fixed
122
+ ? profile.page.height
123
+ : (ref?.computedHeight ?? profile.page.height),
124
+ });
125
+ next.setZIndex?.(after + 1);
126
+ store.selectPage(next.id);
127
+ };
128
+
129
+ return (
130
+ <div className="group/insert relative h-3">
131
+ <button
132
+ type="button"
133
+ aria-label={t("detailPage.pageToolbar.addBelow")}
134
+ title={t("detailPage.pageToolbar.addBelow")}
135
+ onClick={insert}
136
+ className="absolute inset-x-0 top-0 flex h-3 items-center justify-center"
137
+ >
138
+ <span className="h-px flex-1 bg-dpe-ink-200 opacity-0 transition-opacity group-hover/insert:opacity-100" />
139
+ <span className="mx-1 flex h-4 w-4 items-center justify-center rounded-full border border-dpe-ink-200 bg-dpe-surface text-dpe-ink-500 opacity-0 transition-opacity group-hover/insert:opacity-100">
140
+ <Plus aria-hidden="true" size={11} />
141
+ </span>
142
+ <span className="h-px flex-1 bg-dpe-ink-200 opacity-0 transition-opacity group-hover/insert:opacity-100" />
143
+ </button>
144
+ </div>
145
+ );
146
+ }
147
+
97
148
  // ── Sortable row ────────────────────────────────────────────────────────────────
98
149
 
99
150
  const PageRow = observer(function PageRow({
@@ -120,8 +171,7 @@ const PageRow = observer(function PageRow({
120
171
  const active = store.activePage?.id === page.id;
121
172
  const ratio = page.computedHeight / Math.max(1, page.computedWidth);
122
173
  const { role, title } = pageLabel(page, index, t);
123
- const [editingBackground, setEditingBackground] = useState(false);
124
- const background = page.background || "#ffffff";
174
+ const maxPages = detailPageEditorProfile().maxPages;
125
175
 
126
176
  const style: React.CSSProperties = {
127
177
  transform: CSS.Transform.toString(transform),
@@ -149,70 +199,71 @@ const PageRow = observer(function PageRow({
149
199
  ].join(" ")}
150
200
  >
151
201
  <div className="flex items-center gap-2">
152
- <span
153
- {...listeners}
154
- aria-label={t("detailPage.pages.reorderHandle")}
155
- className={[
156
- "shrink-0 touch-none rounded text-dpe-ink-300 hover:text-dpe-ink-500",
157
- isDragging ? "cursor-grabbing" : "cursor-grab",
158
- ].join(" ")}
159
- >
160
- <GripVertical aria-hidden="true" size={16} />
161
- </span>
162
- <div
163
- className="relative shrink-0 overflow-hidden rounded-dpe-md border border-dpe-ink-100 bg-dpe-ink-50"
164
- style={{ width: 64, height: Math.min(80, Math.max(40, 64 * ratio)) }}
165
- >
166
- {thumb ? (
167
- // eslint-disable-next-line @next/next/no-img-element
168
- <img
169
- src={thumb}
170
- alt={title || t("detailPage.pages.pageN", { number: index + 1 })}
171
- draggable={false}
172
- className="h-full w-full object-cover object-top"
173
- />
174
- ) : null}
175
- </div>
176
- <div className="min-w-0 flex-1">
177
- <p className="truncate text-sm">
178
- <span className="text-dpe-ink-400">{role}</span>
179
- {title ? (
180
- <>
181
- <span className="mx-1 text-dpe-ink-300">|</span>
182
- <span className="font-dpe-bold text-dpe-ink-900">{title}</span>
183
- </>
184
- ) : null}
185
- </p>
186
- </div>
187
- {/*
188
- 페이지 배경. 우측 인스펙터에도 같은 컨트롤이 있지만 **아무것도 선택 안 됐을 때만**
189
- 뜬다 — 20섹션짜리 문서를 만지는 동안 선택이 비는 순간이 거의 없어서 사실상 안 보였다.
190
- 쓰기는 우측과 똑같이 `page.set({background})`이라 상태가 갈라지지 않는다.
191
- */}
192
- <button
193
- type="button"
194
- onClick={(event) => {
195
- event.stopPropagation();
196
- setEditingBackground((open) => !open);
197
- }}
198
- aria-label={t("detailPage.pages.background")}
199
- aria-expanded={editingBackground}
200
- title={t("detailPage.pages.background")}
201
- className="h-6 w-6 shrink-0 rounded-dpe-md border border-dpe-ink-200 hover:border-dpe-ink-400"
202
- style={{ background }}
203
- />
204
- </div>
205
- {editingBackground ? (
202
+ <span
203
+ {...listeners}
204
+ aria-label={t("detailPage.pages.reorderHandle")}
205
+ className={[
206
+ "shrink-0 touch-none rounded text-dpe-ink-300 hover:text-dpe-ink-500",
207
+ isDragging ? "cursor-grabbing" : "cursor-grab",
208
+ ].join(" ")}
209
+ >
210
+ <GripVertical aria-hidden="true" size={16} />
211
+ </span>
206
212
  <div
207
- className="mt-2 border-t border-dpe-ink-100 pt-2"
208
- onClick={(event) => event.stopPropagation()}
213
+ className="relative shrink-0 overflow-hidden rounded-dpe-md border border-dpe-ink-100 bg-dpe-ink-50"
214
+ style={{ width: 64, height: Math.min(80, Math.max(40, 64 * ratio)) }}
209
215
  >
210
- <FillControl
211
- value={background}
212
- onChange={(next) => page.set?.({ background: next })}
213
- />
216
+ {thumb ? (
217
+ // eslint-disable-next-line @next/next/no-img-element
218
+ <img
219
+ src={thumb}
220
+ alt={title || t("detailPage.pages.pageN", { number: index + 1 })}
221
+ draggable={false}
222
+ className="h-full w-full object-cover object-top"
223
+ />
224
+ ) : null}
225
+ </div>
226
+ <div className="min-w-0 flex-1">
227
+ <p className="truncate text-sm">
228
+ <span className="text-dpe-ink-400">{role}</span>
229
+ {title ? (
230
+ <>
231
+ <span className="mx-1 text-dpe-ink-300">|</span>
232
+ <span className="font-dpe-bold text-dpe-ink-900">{title}</span>
233
+ </>
234
+ ) : null}
235
+ </p>
236
+ </div>
237
+ {/* 복제 / 삭제. 판을 고르지 않아도 목록에서 바로 되게 둔다. */}
238
+ <div className="flex shrink-0 flex-col gap-1">
239
+ <button
240
+ type="button"
241
+ disabled={store.pages.length >= maxPages}
242
+ onClick={(event) => {
243
+ event.stopPropagation();
244
+ page.clone?.();
245
+ }}
246
+ aria-label={t("detailPage.pageToolbar.duplicate")}
247
+ title={t("detailPage.pageToolbar.duplicate")}
248
+ className="flex h-6 w-6 items-center justify-center rounded-dpe-md border border-dpe-ink-200 text-dpe-ink-500 hover:border-dpe-ink-400 hover:text-dpe-ink-800 disabled:cursor-not-allowed disabled:text-dpe-ink-200 disabled:hover:border-dpe-ink-200"
249
+ >
250
+ <Copy aria-hidden="true" size={13} />
251
+ </button>
252
+ <button
253
+ type="button"
254
+ disabled={store.pages.length <= 1}
255
+ onClick={(event) => {
256
+ event.stopPropagation();
257
+ store.deletePages?.([page.id]);
258
+ }}
259
+ aria-label={t("detailPage.pageToolbar.delete")}
260
+ title={t("detailPage.pageToolbar.delete")}
261
+ className="flex h-6 w-6 items-center justify-center rounded-dpe-md border border-dpe-ink-200 text-dpe-ink-500 hover:border-dpe-ink-400 hover:text-dpe-danger-600 disabled:cursor-not-allowed disabled:text-dpe-ink-200 disabled:hover:border-dpe-ink-200"
262
+ >
263
+ <Trash2 aria-hidden="true" size={13} />
264
+ </button>
214
265
  </div>
215
- ) : null}
266
+ </div>
216
267
  </div>
217
268
  );
218
269
  });
@@ -249,7 +300,9 @@ export const DetailPagePagesPanel = observer(function DetailPagePagesPanel({
249
300
  if (!over || active.id === over.id) return;
250
301
  const toIndex = s.pages.findIndex((p) => p.id === over.id);
251
302
  if (toIndex === -1) return;
252
- s.setPageZIndex(active.id as string, toIndex);
303
+ // 순서를 바꾸는 것은 페이지 자신이다. 스토어에 `setPageZIndex` 는 없어서
304
+ // 끌어다 놓을 때마다 «is not a function» 으로 죽었다.
305
+ s.pages.find((p) => p.id === active.id)?.setZIndex?.(toIndex);
253
306
  },
254
307
  [s],
255
308
  );
@@ -263,10 +316,12 @@ export const DetailPagePagesPanel = observer(function DetailPagePagesPanel({
263
316
  ? Math.round(
264
317
  typeof profile.page.height === "number"
265
318
  ? profile.page.height
266
- : s.pages[0]?.computedHeight ?? 0,
319
+ : (s.pages[0]?.computedHeight ?? 0),
267
320
  )
268
321
  : Math.round(s.pages.reduce((acc, p) => acc + p.computedHeight, 0));
269
322
 
323
+ const canAdd = s.pages.length < profile.maxPages;
324
+
270
325
  return (
271
326
  <div className="flex h-full flex-col">
272
327
  <div className="flex items-baseline justify-between px-4 py-3">
@@ -291,13 +346,17 @@ export const DetailPagePagesPanel = observer(function DetailPagePagesPanel({
291
346
  >
292
347
  <div className="space-y-2">
293
348
  {s.pages.map((page, index) => (
294
- <PageRow
295
- key={page.id}
296
- store={s}
297
- page={page}
298
- index={index}
299
- thumb={detailPageThumbnailBus.get(page.id)}
300
- />
349
+ <Fragment key={page.id}>
350
+ <PageRow
351
+ store={s}
352
+ page={page}
353
+ index={index}
354
+ thumb={detailPageThumbnailBus.get(page.id)}
355
+ />
356
+ {/* 새 화면은 «어디에» 가 먼저다. 목록 끝에 버튼 하나를 두면 넣고 나서
357
+ 다시 끌어 옮기게 되므로, 넣을 자리마다 하나씩 둔다. */}
358
+ <InsertHere store={s} after={index} disabled={!canAdd} />
359
+ </Fragment>
301
360
  ))}
302
361
  </div>
303
362
  </SortableContext>
@@ -22,6 +22,7 @@ import {
22
22
  Strikethrough,
23
23
  Highlighter,
24
24
  Sparkles,
25
+ Copy,
25
26
  Trash2,
26
27
  Ungroup,
27
28
  Type as TypeIcon,
@@ -179,6 +180,7 @@ type ElementLike = {
179
180
  type PageLike = {
180
181
  id: string;
181
182
  background?: string;
183
+ clone?: () => void;
182
184
  width?: number;
183
185
  height?: number;
184
186
  computedWidth?: number;
@@ -195,6 +197,7 @@ type StoreLike = {
195
197
  fonts?: FontCatalogStore["fonts"];
196
198
  addFont?: FontCatalogStore["addFont"];
197
199
  deleteElements?: (ids: string[]) => void;
200
+ deletePages?: (ids: string[]) => void;
198
201
  ungroupElements?: (ids: string[]) => void;
199
202
  };
200
203
 
@@ -2233,15 +2236,32 @@ const PageInspector = observer(function PageInspector({
2233
2236
  const { t } = useTranslation("branding");
2234
2237
  const profile = detailPageEditorProfile();
2235
2238
  const page = store.activePage ?? store.pages[0];
2236
- const bg = str(page?.background, "#ffffff");
2237
2239
  return (
2238
2240
  <>
2239
2241
  {profile.page.fixed ? null : <PageHeightSection page={page} />}
2240
- <Section title={t("detailPage.properties.pageBackground")}>
2241
- <FillControl value={bg} onChange={(c) => page?.set?.({ background: c })} />
2242
- <p className="mt-2 text-xs text-dpe-ink-400">
2243
- {t("detailPage.properties.pageBackgroundHint")}
2244
- </p>
2242
+ {/* 화면을 통째로 다루는 두 가지. 예전에는 캔버스 옆 세로 띠에 있었는데, 판을
2243
+ 가리는 데 비해 여기가 이미 «이 화면» 을 다루는 자리다. */}
2244
+ <Section title={t("detailPage.properties.pageActions")}>
2245
+ <div className="flex gap-2">
2246
+ <button
2247
+ type="button"
2248
+ disabled={!page?.clone || store.pages.length >= profile.maxPages}
2249
+ onClick={() => page?.clone?.()}
2250
+ className="inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded-dpe-md border border-dpe-ink-200 bg-dpe-surface text-xs font-dpe-semibold text-dpe-ink-700 hover:bg-dpe-ink-50 disabled:cursor-not-allowed disabled:text-dpe-ink-300"
2251
+ >
2252
+ <Copy aria-hidden="true" size={15} />
2253
+ {t("detailPage.pageToolbar.duplicate")}
2254
+ </button>
2255
+ <button
2256
+ type="button"
2257
+ disabled={store.pages.length <= 1}
2258
+ onClick={() => page && store.deletePages?.([page.id])}
2259
+ className="inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded-dpe-md border border-dpe-ink-200 bg-dpe-surface text-xs font-dpe-semibold text-dpe-ink-700 hover:bg-dpe-danger-50 hover:text-dpe-danger-700 disabled:cursor-not-allowed disabled:text-dpe-ink-300"
2260
+ >
2261
+ <Trash2 aria-hidden="true" size={15} />
2262
+ {t("detailPage.pageToolbar.delete")}
2263
+ </button>
2264
+ </div>
2245
2265
  </Section>
2246
2266
  {onGenerateDataGif ? (
2247
2267
  <CellGridGifSection
@@ -2288,11 +2308,12 @@ export const ElementAiEditPanel = observer(function ElementAiEditPanel({
2288
2308
  // http(s) URL이면 그대로 넘긴다. 402는 크레딧 부족 마커로 승격.
2289
2309
  const editImage = useCallback<GenerateImageFn>(
2290
2310
  async ({ prompt, tier, brandId, annotatedImage }) => {
2291
- if (!single || !generatedId) return [];
2311
+ // 문서 id 는 없어도 된다 — 그림과 지시만으로 도는 일이다(캐러셀이 그렇다).
2312
+ if (!single) return [];
2292
2313
  const src = str(single.src);
2293
2314
  const isData = src.startsWith("data:");
2294
2315
  try {
2295
- const res = await api.promptEditDetailPageImage(generatedId, {
2316
+ const res = await api.promptEditDetailPageImage(generatedId ?? null, {
2296
2317
  slot_role: slotRole,
2297
2318
  current_image_url: isData ? undefined : src,
2298
2319
  current_image_base64: isData ? src.split(",")[1] : undefined,
@@ -11,7 +11,6 @@ import {
11
11
  Images,
12
12
  Layers,
13
13
  LayoutTemplate,
14
- Palette,
15
14
  Shapes,
16
15
  Sparkles,
17
16
  Type,
@@ -31,7 +30,6 @@ import { DetailPageElementsPanel } from "./detail-page-elements-panel";
31
30
  import { DetailPageMyShapesPanel } from "./detail-page-my-shapes-panel";
32
31
  import { DetailPageBrandGifsPanel } from "./detail-page-brand-gifs-panel";
33
32
  import { DetailPageReferencesPanel } from "./detail-page-references-panel";
34
- import { DetailPageBrandKitPanel } from "./detail-page-brand-kit-panel";
35
33
  import { DetailPageTextPanel } from "./detail-page-text-panel";
36
34
  import { DetailPagePhotosPanel } from "./detail-page-photos-panel";
37
35
 
@@ -72,7 +70,6 @@ const FALLBACK_LABELS: Record<string, string> = {
72
70
  "detailPage.sidebar.charts": "차트",
73
71
  "detailPage.sidebar.tables": "표",
74
72
  "detailPage.sidebar.ai": "AI 생성",
75
- "detailPage.sidebar.brandKit": "브랜드 킷",
76
73
  "detailPage.sidebar.brandImages": "브랜드 이미지",
77
74
  "detailPage.sidebar.brandGifs": "브랜드 GIF",
78
75
  "detailPage.sidebar.brandShapes": "브랜드 도형",
@@ -182,10 +179,9 @@ export function buildDetailPageSections({
182
179
  const PhotosTab = makeIconTab(t("detailPage.sidebar.photos"), Image);
183
180
  const AiTab = makeIconTab(t("detailPage.sidebar.ai"), Sparkles);
184
181
  // 브랜드 자산 구역의 첫 탭 — 여기서부터 "내가 가진 것"이라 위에 선을 하나 긋는다.
185
- const BrandKitTab = makeIconTab(t("detailPage.sidebar.brandKit"), Palette, {
182
+ const MyImagesTab = makeIconTab(t("detailPage.sidebar.brandImages"), Images, {
186
183
  dividerBefore: true,
187
184
  });
188
- const MyImagesTab = makeIconTab(t("detailPage.sidebar.brandImages"), Images);
189
185
  const BrandGifsTab = makeIconTab(t("detailPage.sidebar.brandGifs"), Film);
190
186
  // 도형·차트·표를 품는 한 탭. 아이콘은 "내 도형"(Shapes)과 겹치지 않게 갈랐다.
191
187
  const ElementsTab = makeIconTab(t("detailPage.sidebar.elements"), Component);
@@ -259,12 +255,6 @@ export function buildDetailPageSections({
259
255
  Panel: () => <DetailPageReferencesPanel generatedId={generatedId} />,
260
256
  };
261
257
 
262
- const brandKitSection: CanvasSection = {
263
- name: "brand-kit",
264
- Tab: BrandKitTab,
265
- Panel: ({ store }) => <DetailPageBrandKitPanel store={store} />,
266
- };
267
-
268
258
  // 요소: 클릭 한 번으로 캔버스에 놓이는 것들을 한 서랍에 모은다.
269
259
  // - 도형: 공용 라이브러리(우리 템플릿에서 추린 범용 SVG) + 스톡 기본 도형/라인.
270
260
  // - 차트: 데이터가 붙은 프리셋. 놓으면 그룹 하나로 들어가고 값·종류는 우측에서.
@@ -312,16 +302,17 @@ export function buildDetailPageSections({
312
302
  return [
313
303
  ...(structureSection ? [structureSection] : []),
314
304
  pagesSection,
305
+ // 레이어는 페이지 바로 밑이다 — 둘 다 "이 문서가 무엇으로 되어 있나"를 보는 자리라
306
+ // 붙어 있어야 오간다. 아래쪽은 넣을 것을 고르는 서랍들이다.
307
+ layersSection,
315
308
  textSection,
316
309
  photosSection,
317
310
  elementsSection,
318
311
  aiSection,
319
- // ↓ 여기부터 브랜드 자산(브랜드 킷 탭이 위에 구분선을 그린다).
320
- brandKitSection,
312
+ // ↓ 여기부터 브랜드 자산('내 이미지' 탭이 위에 구분선을 그린다).
321
313
  myImagesSection,
322
314
  brandGifsSection,
323
315
  referencesSection,
324
316
  myShapesSection,
325
- layersSection,
326
317
  ];
327
318
  }
package/i18n/en.json CHANGED
@@ -104,8 +104,7 @@
104
104
  "pageHeightFit": "Fit to content",
105
105
  "pageHeightOverflow": "Content reaches {{px}}px and overflows the bottom of this section.",
106
106
  "pageHeightHint": "You can also drag the handle below the canvas.",
107
- "pageBackground": "Page background",
108
- "pageBackgroundHint": "Select an element to edit its font, color, and alignment here.",
107
+ "pageActions": "This page",
109
108
  "selectionNone": "No selection",
110
109
  "typeText": "Text",
111
110
  "typeImage": "Image",
@@ -414,7 +413,6 @@
414
413
  "uploadImage": "Upload brand image",
415
414
  "imagesEmpty": "No images saved for this brand.",
416
415
  "openMoodboard": "Open moodboard",
417
- "kitEmpty": "No brand moodboard is available.",
418
416
  "activeBrand": "Active brand",
419
417
  "shapeUploaded": "Brand shape uploaded.",
420
418
  "shapeLoadFailed": "Could not load the source shape.",
@@ -835,7 +833,6 @@
835
833
  "text": "Text",
836
834
  "photos": "Photos",
837
835
  "ai": "AI",
838
- "brandKit": "Brand kit",
839
836
  "brandImages": "Brand images",
840
837
  "brandGifs": "Brand GIFs",
841
838
  "brandShapes": "Brand shapes",
package/i18n/ko.json CHANGED
@@ -104,8 +104,7 @@
104
104
  "pageHeightFit": "내용에 맞추기",
105
105
  "pageHeightOverflow": "내용이 {{px}}px까지 내려가 화면 아래로 넘칩니다.",
106
106
  "pageHeightHint": "캔버스 아래 손잡이를 끌어서도 바꿀 수 있어요.",
107
- "pageBackground": "페이지 배경",
108
- "pageBackgroundHint": "요소를 선택하면 글꼴·색상·정렬을 여기서 편집할 수 있어요.",
107
+ "pageActions": "이 화면",
109
108
  "selectionNone": "선택 없음",
110
109
  "typeText": "텍스트",
111
110
  "typeImage": "이미지",
@@ -414,7 +413,6 @@
414
413
  "uploadImage": "브랜드 이미지 업로드",
415
414
  "imagesEmpty": "이 브랜드에 저장된 이미지가 없어요.",
416
415
  "openMoodboard": "무드보드 열기",
417
- "kitEmpty": "사용 가능한 브랜드 무드보드가 없어요.",
418
416
  "activeBrand": "현재 브랜드",
419
417
  "shapeUploaded": "브랜드 도형을 업로드했어요.",
420
418
  "shapeLoadFailed": "도형 원본을 불러오지 못했어요.",
@@ -835,7 +833,6 @@
835
833
  "text": "텍스트",
836
834
  "photos": "사진",
837
835
  "ai": "AI 생성",
838
- "brandKit": "브랜드 킷",
839
836
  "brandImages": "브랜드 이미지",
840
837
  "brandGifs": "브랜드 GIF",
841
838
  "brandShapes": "브랜드 도형",
@@ -22,7 +22,13 @@ export type ActionElement = {
22
22
  };
23
23
 
24
24
  export type ActionContext = {
25
- /** 생성 인스턴스가 있는가. 없으면 프롬프트 편집을 서버가 못 받는다(픽스처). */
25
+ /**
26
+ * 서버가 아는 문서가 있는가.
27
+ *
28
+ * 글·도형·그룹을 프롬프트로 고치는 일은 서버가 문서를 읽고 고쳐서 돌려주므로 이것이
29
+ * 필요하다. **그림 편집은 아니다** — 그림과 지시가 요청에 다 실려 가고 결과는 브랜드
30
+ * 자산으로 돌아온다. 그래서 문서가 없는 편집기(캐러셀)에서도 그림만은 고칠 수 있다.
31
+ */
26
32
  hasGeneration?: boolean;
27
33
  /** 배경 지우기가 배선돼 있는가. */
28
34
  canRemoveBackground?: boolean;
@@ -62,13 +68,14 @@ export function quickActions(
62
68
  if (canRemoveBackground) actions.push("bgRemove");
63
69
  }
64
70
 
65
- if (hasGeneration) {
66
- const editable =
67
- el.type === "text" ||
68
- el.type === "svg" ||
69
- el.type === "image" ||
70
- (el.type === "group" && hasEditableDescendant(el));
71
- if (editable) actions.push("promptEdit");
71
+ // 그림은 그 자체가 요청에 실려 가므로 문서가 필요 없다. 대신 실을 그림이 있어야 한다.
72
+ const editableImage = el.type === "image" && Boolean(str(el.src));
73
+ const needsDocument =
74
+ el.type === "text" ||
75
+ el.type === "svg" ||
76
+ (el.type === "group" && hasEditableDescendant(el));
77
+ if (editableImage || (needsDocument && hasGeneration)) {
78
+ actions.push("promptEdit");
72
79
  }
73
80
 
74
81
  actions.push("more");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviosa-ai/detail-page-editor",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Leviosa detail-page editor shell. Source-only: consumers compile it through Next transpilePackages. Every app coupling (sourcing server, brand store, cache keys, toast) arrives through the DetailPageHost interface, and every URL it fetches comes from configureDetailPageEditor.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -1,77 +0,0 @@
1
- "use client";
2
-
3
- import { useQuery } from "@tanstack/react-query";
4
- import { Loader2, Palette } from "lucide-react";
5
- import { useTranslation } from "react-i18next";
6
-
7
- import { useDetailPageHost } from "./detail-page-host-context";
8
-
9
- export function DetailPageBrandKitPanel({ store }: { store: unknown }) {
10
- // 킷을 그리는 화면은 브랜드 도메인이라 호스트가 꽂는다. 편집기는 활성 브랜드의
11
- // 무드보드를 불러 킷으로 바꾼 뒤 그 자리에 넘길 뿐이다.
12
- const { brand, queryKeys, slots } = useDetailPageHost();
13
- const { t } = useTranslation("branding");
14
- const {
15
- brands,
16
- activeBrand,
17
- activeBrandId,
18
- setActiveBrandId,
19
- isLoading: brandsLoading,
20
- } = brand.useBrandWorkspace();
21
- const assetsQuery = useQuery({
22
- queryKey: queryKeys.branding.brandAssets(activeBrandId),
23
- queryFn: ({ signal }) => brand.listBrandAssets(activeBrandId!, signal),
24
- enabled: Boolean(activeBrandId),
25
- staleTime: 4 * 60_000,
26
- });
27
- const moodboardQuery = useQuery({
28
- queryKey: queryKeys.branding.brandMoodboard(activeBrandId),
29
- queryFn: ({ signal }) =>
30
- brand.loadBrandMoodboard(activeBrand!, assetsQuery.data ?? [], signal),
31
- enabled: Boolean(activeBrand && assetsQuery.data),
32
- });
33
-
34
- if (brandsLoading || assetsQuery.isLoading || moodboardQuery.isLoading) {
35
- return (
36
- <div className="flex h-full items-center justify-center text-dpe-ink-400">
37
- <Loader2 className="animate-spin" size={20} />
38
- </div>
39
- );
40
- }
41
-
42
- const KitPanel = slots?.BrandKitPanel;
43
- if (!activeBrand || !moodboardQuery.data || !KitPanel) {
44
- return (
45
- <div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-dpe-ink-400">
46
- <Palette size={22} />
47
- <p className="text-xs">{t("detailPage.brandAssets.kitEmpty")}</p>
48
- </div>
49
- );
50
- }
51
-
52
- return (
53
- <div className="flex h-full flex-col overflow-y-auto">
54
- <div className="border-b border-dpe-ink-200 p-3">
55
- <label className="grid gap-1 text-[11px] font-dpe-medium text-dpe-ink-500">
56
- {t("detailPage.brandAssets.activeBrand")}
57
- <select
58
- value={activeBrand.id}
59
- onChange={(event) => setActiveBrandId(event.target.value)}
60
- className="h-8 rounded-dpe-md border border-dpe-ink-200 bg-dpe-surface px-2 text-xs text-dpe-ink-800"
61
- >
62
- {brands.map((brand) => (
63
- <option key={brand.id} value={brand.id}>
64
- {brand.name}
65
- </option>
66
- ))}
67
- </select>
68
- </label>
69
- </div>
70
- <KitPanel
71
- kit={brand.deriveBrandKit(moodboardQuery.data)}
72
- store={store as never}
73
- className="flex-1"
74
- />
75
- </div>
76
- );
77
- }