@leviosa-ai/detail-page-editor 0.10.0 → 0.11.0
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.
- package/components/detail-page/detail-page-host-context.tsx +7 -27
- package/components/detail-page/detail-page-page-toolbar.tsx +25 -150
- package/components/detail-page/detail-page-pages-panel.tsx +69 -11
- package/components/detail-page/detail-page-properties-panel.tsx +29 -8
- package/components/detail-page/detail-page-sidebar-sections.tsx +5 -14
- package/i18n/en.json +1 -4
- package/i18n/ko.json +1 -4
- package/lib/detail-page/selection-actions.ts +15 -8
- package/package.json +1 -1
- package/components/detail-page/detail-page-brand-kit-panel.tsx +0 -77
|
@@ -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
|
-
*
|
|
15
|
+
* 활성 화면 옆에 뜨는 동그란 버튼 하나 — «이 화면 다시 만들기».
|
|
25
16
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
<
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
{
|
|
137
|
-
|
|
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, useState, 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 { GripVertical, Plus } from "lucide-react";
|
|
9
9
|
import {
|
|
10
10
|
DndContext,
|
|
11
11
|
closestCenter,
|
|
@@ -53,12 +53,14 @@ type PageLike = {
|
|
|
53
53
|
/** 단색 hex 또는 `linear-gradient(...)` 문자열. `FillControl`이 둘 다 만든다. */
|
|
54
54
|
background?: string;
|
|
55
55
|
set?: (props: Record<string, unknown>) => 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
|
-
|
|
63
|
+
addPage: (props: Record<string, unknown>) => PageLike;
|
|
62
64
|
};
|
|
63
65
|
|
|
64
66
|
type TFn = (key: string, opts?: Record<string, unknown>) => string;
|
|
@@ -94,6 +96,54 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({
|
|
|
94
96
|
x: 0,
|
|
95
97
|
});
|
|
96
98
|
|
|
99
|
+
/** 두 화면 사이에 새 화면을 끼우는 자리. 평소엔 선 한 줄, 올리면 «+» 가 뜬다. */
|
|
100
|
+
function InsertHere({
|
|
101
|
+
store,
|
|
102
|
+
after,
|
|
103
|
+
disabled,
|
|
104
|
+
}: {
|
|
105
|
+
store: StoreLike;
|
|
106
|
+
after: number;
|
|
107
|
+
disabled?: boolean;
|
|
108
|
+
}) {
|
|
109
|
+
const { t } = useTranslation("branding");
|
|
110
|
+
if (disabled) return null;
|
|
111
|
+
|
|
112
|
+
const insert = () => {
|
|
113
|
+
const ref = store.pages[after];
|
|
114
|
+
const profile = detailPageEditorProfile();
|
|
115
|
+
const next = store.addPage({
|
|
116
|
+
width:
|
|
117
|
+
profile.page.width === "document"
|
|
118
|
+
? ref?.computedWidth ?? "auto"
|
|
119
|
+
: profile.page.width,
|
|
120
|
+
height: profile.page.fixed
|
|
121
|
+
? profile.page.height
|
|
122
|
+
: ref?.computedHeight ?? profile.page.height,
|
|
123
|
+
});
|
|
124
|
+
next.setZIndex?.(after + 1);
|
|
125
|
+
store.selectPage(next.id);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className="group/insert relative h-3">
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
aria-label={t("detailPage.pageToolbar.addBelow")}
|
|
133
|
+
title={t("detailPage.pageToolbar.addBelow")}
|
|
134
|
+
onClick={insert}
|
|
135
|
+
className="absolute inset-x-0 top-0 flex h-3 items-center justify-center"
|
|
136
|
+
>
|
|
137
|
+
<span className="h-px flex-1 bg-dpe-ink-200 opacity-0 transition-opacity group-hover/insert:opacity-100" />
|
|
138
|
+
<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">
|
|
139
|
+
<Plus aria-hidden="true" size={11} />
|
|
140
|
+
</span>
|
|
141
|
+
<span className="h-px flex-1 bg-dpe-ink-200 opacity-0 transition-opacity group-hover/insert:opacity-100" />
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
97
147
|
// ── Sortable row ────────────────────────────────────────────────────────────────
|
|
98
148
|
|
|
99
149
|
const PageRow = observer(function PageRow({
|
|
@@ -249,7 +299,9 @@ export const DetailPagePagesPanel = observer(function DetailPagePagesPanel({
|
|
|
249
299
|
if (!over || active.id === over.id) return;
|
|
250
300
|
const toIndex = s.pages.findIndex((p) => p.id === over.id);
|
|
251
301
|
if (toIndex === -1) return;
|
|
252
|
-
|
|
302
|
+
// 순서를 바꾸는 것은 페이지 자신이다. 스토어에 `setPageZIndex` 는 없어서
|
|
303
|
+
// 끌어다 놓을 때마다 «is not a function» 으로 죽었다.
|
|
304
|
+
s.pages.find((p) => p.id === active.id)?.setZIndex?.(toIndex);
|
|
253
305
|
},
|
|
254
306
|
[s],
|
|
255
307
|
);
|
|
@@ -267,6 +319,8 @@ export const DetailPagePagesPanel = observer(function DetailPagePagesPanel({
|
|
|
267
319
|
)
|
|
268
320
|
: Math.round(s.pages.reduce((acc, p) => acc + p.computedHeight, 0));
|
|
269
321
|
|
|
322
|
+
const canAdd = s.pages.length < profile.maxPages;
|
|
323
|
+
|
|
270
324
|
return (
|
|
271
325
|
<div className="flex h-full flex-col">
|
|
272
326
|
<div className="flex items-baseline justify-between px-4 py-3">
|
|
@@ -291,13 +345,17 @@ export const DetailPagePagesPanel = observer(function DetailPagePagesPanel({
|
|
|
291
345
|
>
|
|
292
346
|
<div className="space-y-2">
|
|
293
347
|
{s.pages.map((page, index) => (
|
|
294
|
-
<
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
348
|
+
<Fragment key={page.id}>
|
|
349
|
+
<PageRow
|
|
350
|
+
store={s}
|
|
351
|
+
page={page}
|
|
352
|
+
index={index}
|
|
353
|
+
thumb={detailPageThumbnailBus.get(page.id)}
|
|
354
|
+
/>
|
|
355
|
+
{/* 새 화면은 «어디에» 가 먼저다. 목록 끝에 버튼 하나를 두면 넣고 나서
|
|
356
|
+
다시 끌어 옮기게 되므로, 넣을 자리마다 하나씩 둔다. */}
|
|
357
|
+
<InsertHere store={s} after={index} disabled={!canAdd} />
|
|
358
|
+
</Fragment>
|
|
301
359
|
))}
|
|
302
360
|
</div>
|
|
303
361
|
</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
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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.
|
|
3
|
+
"version": "0.11.0",
|
|
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
|
-
}
|