@leviosa-ai/detail-page-editor 0.11.8 → 0.12.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.
- package/components/cardnews/color-input.tsx +43 -151
- package/components/detail-page/canvas-context-menu.tsx +3 -0
- package/components/detail-page/detail-page-download-dialog.tsx +22 -19
- package/components/detail-page/detail-page-properties-panel.tsx +13 -8
- package/components/detail-page/detail-page-references-panel.tsx +13 -12
- package/components/detail-page/edit-quota-ui.tsx +6 -6
- package/components/detail-page/generation-progress-fill.tsx +4 -4
- package/components/detail-page/group-prompt-edit-panel.tsx +6 -6
- package/components/detail-page/prompt-edit-panel.tsx +6 -6
- package/components/detail-page/spec-prompt-edit-panel.tsx +7 -7
- package/components/detail-page/svg-prompt-edit-panel.tsx +6 -6
- package/components/ui/button.tsx +20 -7
- package/components/ui/dialog.tsx +9 -3
- package/components/ui/select.tsx +25 -7
- package/components/ui/slider.tsx +8 -3
- package/components/ui/switch.tsx +9 -2
- package/components/ui/tooltip.tsx +10 -2
- package/i18n/en.json +10 -0
- package/i18n/ko.json +10 -0
- package/lib/detail-page/editor-profile.ts +14 -1
- package/package.json +2 -2
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Pipette } from "lucide-react";
|
|
4
|
-
import { useCallback, useEffect, useMemo,
|
|
5
|
-
import {
|
|
4
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { Popover as PopoverPrimitive } from "radix-ui";
|
|
6
6
|
import { useTranslation } from "react-i18next";
|
|
7
7
|
|
|
8
8
|
// ── Preset palette — popular colors for cardnews/social media ──────────────
|
|
@@ -21,8 +21,6 @@ const PRESET_COLORS = [
|
|
|
21
21
|
|
|
22
22
|
const RECENT_COLORS_KEY = "leviosa-recent-colors";
|
|
23
23
|
const MAX_RECENT = 8;
|
|
24
|
-
const POPOVER_WIDTH = 224; // w-56 = 14rem = 224px
|
|
25
|
-
const POPOVER_HEIGHT_ESTIMATE = 320;
|
|
26
24
|
|
|
27
25
|
/** Checkerboard SVG data URI for transparent backgrounds */
|
|
28
26
|
export const CHECKER_BG = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Crect width='4' height='4' fill='%23ccc'/%3E%3Crect x='4' y='4' width='4' height='4' fill='%23ccc'/%3E%3Crect x='4' width='4' height='4' fill='%23fff'/%3E%3Crect y='4' width='4' height='4' fill='%23fff'/%3E%3C/svg%3E")`;
|
|
@@ -52,35 +50,6 @@ function addRecentColor(color: string): string[] {
|
|
|
52
50
|
return recent;
|
|
53
51
|
}
|
|
54
52
|
|
|
55
|
-
/** Calculate popover position with viewport boundary clamping */
|
|
56
|
-
function calcPopoverPos(
|
|
57
|
-
anchorRect: DOMRect,
|
|
58
|
-
align: "start" | "end",
|
|
59
|
-
side: "auto" | "top" | "bottom",
|
|
60
|
-
): { top: number; left: number } {
|
|
61
|
-
const vw = window.innerWidth;
|
|
62
|
-
const vh = window.innerHeight;
|
|
63
|
-
|
|
64
|
-
let top = side === "top"
|
|
65
|
-
? anchorRect.top - POPOVER_HEIGHT_ESTIMATE - 6
|
|
66
|
-
: anchorRect.bottom + 6;
|
|
67
|
-
let left = align === "end" ? anchorRect.right - POPOVER_WIDTH : anchorRect.left;
|
|
68
|
-
|
|
69
|
-
// Flip upward if overflowing bottom
|
|
70
|
-
if (side === "auto" && top + POPOVER_HEIGHT_ESTIMATE > vh) {
|
|
71
|
-
top = anchorRect.top - POPOVER_HEIGHT_ESTIMATE - 6;
|
|
72
|
-
}
|
|
73
|
-
// Clamp to viewport edges
|
|
74
|
-
if (top < 4) top = 4;
|
|
75
|
-
if (left + POPOVER_WIDTH > vw) left = vw - POPOVER_WIDTH - 4;
|
|
76
|
-
if (left < 4) left = 4;
|
|
77
|
-
|
|
78
|
-
return { top, left };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// ── Global: close other ColorInput popovers when a new one opens ──────────
|
|
82
|
-
const colorInputCloseCallbacks = new Set<() => void>();
|
|
83
|
-
|
|
84
53
|
function isTransparent(v: string): boolean {
|
|
85
54
|
return v === "transparent" || v === "rgba(0,0,0,0)" || v === "rgba(0, 0, 0, 0)";
|
|
86
55
|
}
|
|
@@ -130,10 +99,6 @@ interface ColorInputProps {
|
|
|
130
99
|
size?: "sm" | "md";
|
|
131
100
|
/** Allow transparent color selection (default: false) */
|
|
132
101
|
allowTransparent?: boolean;
|
|
133
|
-
/** Horizontal popover alignment relative to the swatch (default: "end") */
|
|
134
|
-
popoverAlign?: "start" | "end";
|
|
135
|
-
/** Vertical popover placement preference (default: "auto") */
|
|
136
|
-
popoverSide?: "auto" | "top" | "bottom";
|
|
137
102
|
}
|
|
138
103
|
|
|
139
104
|
/**
|
|
@@ -144,17 +109,11 @@ export function ColorInput({
|
|
|
144
109
|
onChange,
|
|
145
110
|
size = "sm",
|
|
146
111
|
allowTransparent = false,
|
|
147
|
-
popoverAlign = "end",
|
|
148
|
-
popoverSide = "auto",
|
|
149
112
|
}: ColorInputProps) {
|
|
150
113
|
const { t } = useTranslation("marketing");
|
|
151
114
|
const [open, setOpen] = useState(false);
|
|
152
|
-
const [measured, setMeasured] = useState(false);
|
|
153
115
|
const [hexInput, setHexInput] = useState("");
|
|
154
116
|
const [recentColors, setRecentColors] = useState<string[]>([]);
|
|
155
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
156
|
-
const popoverRef = useRef<HTMLDivElement>(null);
|
|
157
|
-
const [popoverPos, setPopoverPos] = useState<{ top: number; left: number } | null>(null);
|
|
158
117
|
const swatchPx = size === "md" ? "w-8 h-8" : "w-7 h-7";
|
|
159
118
|
|
|
160
119
|
const isTransparentValue = isTransparent(value);
|
|
@@ -165,88 +124,12 @@ export function ColorInput({
|
|
|
165
124
|
[isTransparentValue, value],
|
|
166
125
|
);
|
|
167
126
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// Register/unregister close callback for mutual exclusion
|
|
127
|
+
// 열릴 때만 최근 색과 hex 칸을 채운다. 닫힌 채로 값이 바뀌어도 다음에 열 때 맞다.
|
|
171
128
|
useEffect(() => {
|
|
172
|
-
|
|
173
|
-
return () => { colorInputCloseCallbacks.delete(closePopover); };
|
|
174
|
-
}, [closePopover]);
|
|
175
|
-
|
|
176
|
-
const openPopover = useCallback(() => {
|
|
177
|
-
// Close all other open ColorInput popovers
|
|
178
|
-
colorInputCloseCallbacks.forEach((cb) => { if (cb !== closePopover) cb(); });
|
|
179
|
-
|
|
129
|
+
if (!open) return;
|
|
180
130
|
setRecentColors(getRecentColors());
|
|
181
131
|
setHexInput(isTransparentValue ? "" : safeColor);
|
|
182
|
-
|
|
183
|
-
if (containerRef.current) {
|
|
184
|
-
setPopoverPos(calcPopoverPos(containerRef.current.getBoundingClientRect(), popoverAlign, popoverSide));
|
|
185
|
-
}
|
|
186
|
-
setOpen(true);
|
|
187
|
-
}, [safeColor, isTransparentValue, closePopover, popoverAlign, popoverSide]);
|
|
188
|
-
|
|
189
|
-
// Track scroll & resize to reposition popover
|
|
190
|
-
useEffect(() => {
|
|
191
|
-
if (!open) return;
|
|
192
|
-
const reposition = () => {
|
|
193
|
-
if (containerRef.current) {
|
|
194
|
-
setPopoverPos(calcPopoverPos(containerRef.current.getBoundingClientRect(), popoverAlign, popoverSide));
|
|
195
|
-
}
|
|
196
|
-
};
|
|
197
|
-
// Capture phase to catch scrolls in any container (e.g. layer panel overflow-y-auto)
|
|
198
|
-
window.addEventListener("scroll", reposition, true);
|
|
199
|
-
window.addEventListener("resize", reposition);
|
|
200
|
-
return () => {
|
|
201
|
-
window.removeEventListener("scroll", reposition, true);
|
|
202
|
-
window.removeEventListener("resize", reposition);
|
|
203
|
-
};
|
|
204
|
-
}, [open, popoverAlign, popoverSide]);
|
|
205
|
-
|
|
206
|
-
// After popover renders, re-measure actual height and adjust position
|
|
207
|
-
useEffect(() => {
|
|
208
|
-
if (!open || !popoverRef.current || !containerRef.current) return;
|
|
209
|
-
const popoverEl = popoverRef.current;
|
|
210
|
-
const anchorRect = containerRef.current.getBoundingClientRect();
|
|
211
|
-
const actualHeight = popoverEl.offsetHeight;
|
|
212
|
-
const vh = window.innerHeight;
|
|
213
|
-
const vw = window.innerWidth;
|
|
214
|
-
|
|
215
|
-
let top = popoverSide === "top"
|
|
216
|
-
? anchorRect.top - actualHeight - 6
|
|
217
|
-
: anchorRect.bottom + 6;
|
|
218
|
-
let left = popoverAlign === "end" ? anchorRect.right - POPOVER_WIDTH : anchorRect.left;
|
|
219
|
-
|
|
220
|
-
if (popoverSide === "auto" && top + actualHeight > vh) {
|
|
221
|
-
top = anchorRect.top - actualHeight - 6;
|
|
222
|
-
}
|
|
223
|
-
if (top < 4) top = 4;
|
|
224
|
-
if (left + POPOVER_WIDTH > vw) left = vw - POPOVER_WIDTH - 4;
|
|
225
|
-
if (left < 4) left = 4;
|
|
226
|
-
|
|
227
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
228
|
-
setPopoverPos({ top, left });
|
|
229
|
-
setMeasured(true);
|
|
230
|
-
}, [open, popoverAlign, popoverSide]);
|
|
231
|
-
|
|
232
|
-
// Close on outside click (check both swatch container and portal popover)
|
|
233
|
-
useEffect(() => {
|
|
234
|
-
if (!open) return;
|
|
235
|
-
const handle = (e: MouseEvent | TouchEvent) => {
|
|
236
|
-
const target = e.target as Node;
|
|
237
|
-
if (
|
|
238
|
-
containerRef.current && !containerRef.current.contains(target) &&
|
|
239
|
-
popoverRef.current && !popoverRef.current.contains(target)
|
|
240
|
-
) {
|
|
241
|
-
setOpen(false);
|
|
242
|
-
}
|
|
243
|
-
};
|
|
244
|
-
document.addEventListener("mousedown", handle);
|
|
245
|
-
document.addEventListener("touchstart", handle);
|
|
246
|
-
return () => {
|
|
247
|
-
document.removeEventListener("mousedown", handle);
|
|
248
|
-
document.removeEventListener("touchstart", handle);
|
|
249
|
-
};
|
|
132
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
250
133
|
}, [open]);
|
|
251
134
|
|
|
252
135
|
const selectColor = useCallback((color: string) => {
|
|
@@ -273,28 +156,38 @@ export function ColorInput({
|
|
|
273
156
|
}, [hexInput, selectColor]);
|
|
274
157
|
|
|
275
158
|
return (
|
|
276
|
-
<
|
|
159
|
+
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
|
277
160
|
{/* Swatch button */}
|
|
278
|
-
<
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
{
|
|
293
|
-
|
|
294
|
-
|
|
161
|
+
<PopoverPrimitive.Trigger asChild>
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
className={`${swatchPx} rounded-dpe-md border border-dpe-ink-200 cursor-pointer shrink-0 transition-colors hover:border-dpe-ink-400`}
|
|
165
|
+
style={{
|
|
166
|
+
backgroundColor: isTransparentValue ? undefined : safeColor,
|
|
167
|
+
backgroundImage: isTransparentValue ? CHECKER_BG : undefined,
|
|
168
|
+
backgroundSize: isTransparentValue ? "8px 8px" : undefined,
|
|
169
|
+
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.1)",
|
|
170
|
+
}}
|
|
171
|
+
title={isTransparentValue ? "transparent" : safeColor}
|
|
172
|
+
/>
|
|
173
|
+
</PopoverPrimitive.Trigger>
|
|
174
|
+
|
|
175
|
+
{/* 자리는 Radix 가 잡는다 — 스와치 바로 아래에 붙이고, 자리가 없으면 알아서 뒤집는다.
|
|
176
|
+
예전에는 좌표를 손으로 재서 `fixed` 로 찍었는데, 스크롤되는 패널 안에서 스와치와
|
|
177
|
+
어긋난 자리에 떴다.
|
|
178
|
+
|
|
179
|
+
포털은 body 로 나가므로 편집기 뿌리 딱지를 다시 달아 준다 — 안 달면 소비자 앱이
|
|
180
|
+
`[data-dpe-root]` 로 좁혀 둔 색·모서리 토큰이 이 팝오버에만 안 먹어서, 각진
|
|
181
|
+
인스펙터 위에 둥근 카드가 하나 뜬다. */}
|
|
182
|
+
<PopoverPrimitive.Portal>
|
|
183
|
+
<PopoverPrimitive.Content
|
|
295
184
|
data-layer-panel
|
|
296
|
-
|
|
297
|
-
|
|
185
|
+
data-dpe-root=""
|
|
186
|
+
side="bottom"
|
|
187
|
+
align="start"
|
|
188
|
+
sideOffset={6}
|
|
189
|
+
collisionPadding={8}
|
|
190
|
+
className="z-[200] w-56 bg-dpe-surface border border-dpe-ink-200 rounded-dpe-md shadow-[0_12px_40px_rgba(0,0,0,0.16)] p-3 space-y-2.5"
|
|
298
191
|
>
|
|
299
192
|
{/* Transparent + Preset palette */}
|
|
300
193
|
<div className="grid grid-cols-6 gap-1.5">
|
|
@@ -302,7 +195,7 @@ export function ColorInput({
|
|
|
302
195
|
<button
|
|
303
196
|
type="button"
|
|
304
197
|
onClick={() => selectColor("transparent")}
|
|
305
|
-
className={`w-7 h-7 rounded-dpe-md border transition-
|
|
198
|
+
className={`w-7 h-7 rounded-dpe-md border transition-colors relative overflow-hidden ${
|
|
306
199
|
isTransparentValue ? "border-[var(--color-primary)] ring-1 ring-[var(--color-primary)]" : "border-[var(--color-border)]"
|
|
307
200
|
}`}
|
|
308
201
|
style={{ backgroundImage: CHECKER_BG, backgroundSize: "8px 8px" }}
|
|
@@ -319,7 +212,7 @@ export function ColorInput({
|
|
|
319
212
|
key={color}
|
|
320
213
|
type="button"
|
|
321
214
|
onClick={() => selectColor(color)}
|
|
322
|
-
className={`w-7 h-7 rounded-dpe-md border transition-
|
|
215
|
+
className={`w-7 h-7 rounded-dpe-md border transition-colors ${
|
|
323
216
|
!isTransparentValue && safeColor.toUpperCase() === color ? "border-[var(--color-primary)] ring-1 ring-[var(--color-primary)]" : "border-[var(--color-border)]"
|
|
324
217
|
}`}
|
|
325
218
|
style={{ backgroundColor: color }}
|
|
@@ -338,7 +231,7 @@ export function ColorInput({
|
|
|
338
231
|
key={color}
|
|
339
232
|
type="button"
|
|
340
233
|
onClick={() => selectColor(color)}
|
|
341
|
-
className={`size-5 rounded-dpe-md border transition-
|
|
234
|
+
className={`size-5 rounded-dpe-md border transition-colors ${
|
|
342
235
|
!isTransparentValue && safeColor.toUpperCase() === color ? "border-[var(--color-primary)] ring-1 ring-[var(--color-primary)]" : "border-[var(--color-border)]"
|
|
343
236
|
}`}
|
|
344
237
|
style={{ backgroundColor: color }}
|
|
@@ -356,7 +249,7 @@ export function ColorInput({
|
|
|
356
249
|
<button
|
|
357
250
|
type="button"
|
|
358
251
|
onClick={openEyeDropper}
|
|
359
|
-
className="flex shrink-0 items-center gap-1 rounded-dpe-
|
|
252
|
+
className="flex shrink-0 items-center gap-1 rounded-dpe-md px-1.5 py-1.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-primary)]"
|
|
360
253
|
title={t("cardnews.colorInput.eyedropper")}
|
|
361
254
|
>
|
|
362
255
|
<Pipette className="w-3.5 h-3.5" />
|
|
@@ -369,12 +262,11 @@ export function ColorInput({
|
|
|
369
262
|
onChange={(e) => setHexInput(e.target.value)}
|
|
370
263
|
onKeyDown={(e) => e.key === "Enter" && handleHexSubmit()}
|
|
371
264
|
placeholder="#000000"
|
|
372
|
-
className="min-w-0 flex-1 rounded-dpe-
|
|
265
|
+
className="min-w-0 flex-1 rounded-dpe-md border border-dpe-ink-200 bg-dpe-surface px-2 py-1.5 text-xs font-mono text-dpe-ink-900 focus:outline-none focus:border-dpe-ink-400"
|
|
373
266
|
/>
|
|
374
267
|
</div>
|
|
375
|
-
</
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
</div>
|
|
268
|
+
</PopoverPrimitive.Content>
|
|
269
|
+
</PopoverPrimitive.Portal>
|
|
270
|
+
</PopoverPrimitive.Root>
|
|
379
271
|
);
|
|
380
272
|
}
|
|
@@ -200,6 +200,9 @@ export const CanvasContextMenu = observer(function CanvasContextMenu({
|
|
|
200
200
|
<div
|
|
201
201
|
role="menu"
|
|
202
202
|
data-dp-canvas-menu
|
|
203
|
+
// 포털이 body 로 나가 편집기 뿌리 밖에 놓인다 — 딱지를 다시 달아야 소비자 앱이
|
|
204
|
+
// `[data-dpe-root]` 로 좁혀 둔 색·모서리 토큰이 이 메뉴에도 먹는다.
|
|
205
|
+
data-dpe-root=""
|
|
203
206
|
style={{ left, top, width: MENU_WIDTH }}
|
|
204
207
|
className="fixed z-[71] rounded-dpe-lg border border-dpe-ink-200 bg-dpe-surface py-1 shadow-lg"
|
|
205
208
|
>
|
|
@@ -58,7 +58,8 @@ type ExportStoreLike = {
|
|
|
58
58
|
};
|
|
59
59
|
|
|
60
60
|
// 등록 플랫폼은 파일명 접미사에만 반영된다(상세페이지 폭은 750px 고정). 셀러가
|
|
61
|
-
// 어디에 올릴지 고르며 맥락을 잡도록 노출한다.
|
|
61
|
+
// 어디에 올릴지 고르며 맥락을 잡도록 노출한다. 캐러셀은 인스타그램 한 곳으로만
|
|
62
|
+
// 나가므로 이 목록을 아예 안 띄운다 — `profile.registerPlatform` 이 그것을 정한다.
|
|
62
63
|
const PLATFORMS = [
|
|
63
64
|
{ value: "naver", label: "네이버 스마트 스토어" },
|
|
64
65
|
{ value: "coupang", label: "쿠팡" },
|
|
@@ -220,7 +221,7 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
|
|
|
220
221
|
setError(null);
|
|
221
222
|
const mime = format === "png" ? "image/png" : "image/jpeg";
|
|
222
223
|
const ext = format === "png" ? "png" : "jpg";
|
|
223
|
-
const base = `${fileName}-${platform}
|
|
224
|
+
const base = profile.registerPlatform ? `${fileName}-${platform}` : fileName;
|
|
224
225
|
try {
|
|
225
226
|
if (format === "psd" || format === "svg" || format === "ai") {
|
|
226
227
|
// 문서 JSON 기반 내보내기. ag-psd 포함 모듈은 이 시점에만 로드한다.
|
|
@@ -384,23 +385,25 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
|
|
|
384
385
|
</Select>
|
|
385
386
|
</div>
|
|
386
387
|
|
|
387
|
-
|
|
388
|
-
<
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
<
|
|
393
|
-
<
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
{p.
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
388
|
+
{profile.registerPlatform && (
|
|
389
|
+
<div className="space-y-1.5">
|
|
390
|
+
<Label className="text-xs text-dpe-ink-500">
|
|
391
|
+
{t("editor.registerPlatform")}
|
|
392
|
+
</Label>
|
|
393
|
+
<Select value={platform} onValueChange={setPlatform}>
|
|
394
|
+
<SelectTrigger className="w-full">
|
|
395
|
+
<SelectValue />
|
|
396
|
+
</SelectTrigger>
|
|
397
|
+
<SelectContent>
|
|
398
|
+
{PLATFORMS.map((p) => (
|
|
399
|
+
<SelectItem key={p.value} value={p.value}>
|
|
400
|
+
{p.label}
|
|
401
|
+
</SelectItem>
|
|
402
|
+
))}
|
|
403
|
+
</SelectContent>
|
|
404
|
+
</Select>
|
|
405
|
+
</div>
|
|
406
|
+
)}
|
|
404
407
|
|
|
405
408
|
<div className="space-y-1.5">
|
|
406
409
|
<Label className="text-xs text-dpe-ink-500">{t("editor.pageScope")}</Label>
|
|
@@ -1509,13 +1509,18 @@ const TextGifSection = observer(function TextGifSection({
|
|
|
1509
1509
|
);
|
|
1510
1510
|
});
|
|
1511
1511
|
|
|
1512
|
-
/**
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1512
|
+
/**
|
|
1513
|
+
* 칸 모양 선택지. 서버 `GET /images/data-gif-effects` 의 SHAPES 와 같아야 한다.
|
|
1514
|
+
*
|
|
1515
|
+
* 이름은 도형 패널이 이미 번역해 둔 것을 그대로 쓴다 — 같은 도형을 두 벌 번역해 두면
|
|
1516
|
+
* 한쪽만 언어를 타서 목록에 한국어와 영어가 섞인다. `square` 만 그쪽 이름이 `rect` 다.
|
|
1517
|
+
*/
|
|
1518
|
+
const CELL_SHAPES: Array<{ id: string; labelKey: string }> = [
|
|
1519
|
+
{ id: "circle", labelKey: "detailPage.shapes.basic.circle" },
|
|
1520
|
+
{ id: "square", labelKey: "detailPage.shapes.basic.rect" },
|
|
1521
|
+
{ id: "rounded", labelKey: "detailPage.shapes.basic.rounded" },
|
|
1522
|
+
{ id: "diamond", labelKey: "detailPage.shapes.basic.diamond" },
|
|
1523
|
+
{ id: "hexagon", labelKey: "detailPage.shapes.basic.hexagon" },
|
|
1519
1524
|
];
|
|
1520
1525
|
|
|
1521
1526
|
/**
|
|
@@ -1756,7 +1761,7 @@ const CellGridGifSection = observer(function CellGridGifSection({
|
|
|
1756
1761
|
>
|
|
1757
1762
|
{CELL_SHAPES.map((option) => (
|
|
1758
1763
|
<option key={option.id} value={option.id}>
|
|
1759
|
-
{option.
|
|
1764
|
+
{t(option.labelKey)}
|
|
1760
1765
|
</option>
|
|
1761
1766
|
))}
|
|
1762
1767
|
</select>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useMemo, useState } from "react";
|
|
4
4
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { useTranslation } from "react-i18next";
|
|
5
6
|
import { BookmarkPlus, ImageOff, Loader2 } from "lucide-react";
|
|
6
7
|
|
|
7
8
|
import { BrandPanelHeader } from "./detail-page-brand-panel-header";
|
|
@@ -38,6 +39,7 @@ export function DetailPageReferencesPanel({
|
|
|
38
39
|
generatedId?: string;
|
|
39
40
|
}) {
|
|
40
41
|
const { api, brand, toast } = useDetailPageHost();
|
|
42
|
+
const { t } = useTranslation("branding");
|
|
41
43
|
const queryClient = useQueryClient();
|
|
42
44
|
const { activeBrand, activeBrandId, isLoading: brandsLoading } = brand.useBrandWorkspace();
|
|
43
45
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -62,10 +64,10 @@ export function DetailPageReferencesPanel({
|
|
|
62
64
|
}
|
|
63
65
|
return [...byGroup.entries()].map(([key, items]) => ({
|
|
64
66
|
key,
|
|
65
|
-
name: items[0]?.reference_name || "
|
|
67
|
+
name: items[0]?.reference_name || t("detailPage.references.untitled"),
|
|
66
68
|
items: [...items].sort((a, b) => a.screen_index - b.screen_index),
|
|
67
69
|
}));
|
|
68
|
-
}, [listQuery.data]);
|
|
70
|
+
}, [listQuery.data, t]);
|
|
69
71
|
|
|
70
72
|
const saveMutation = useMutation({
|
|
71
73
|
mutationFn: async () => {
|
|
@@ -76,7 +78,7 @@ export function DetailPageReferencesPanel({
|
|
|
76
78
|
onSuccess: async (result) => {
|
|
77
79
|
setError(null);
|
|
78
80
|
const screens = result.assets.filter((asset) => asset.role === "screen").length;
|
|
79
|
-
toast.success(
|
|
81
|
+
toast.success(t("detailPage.references.saved", { count: screens }));
|
|
80
82
|
await queryClient.invalidateQueries({
|
|
81
83
|
queryKey: [REFERENCES_QUERY_ROOT, activeBrandId],
|
|
82
84
|
});
|
|
@@ -85,7 +87,7 @@ export function DetailPageReferencesPanel({
|
|
|
85
87
|
setError(
|
|
86
88
|
err instanceof Error && err.message !== "no-instance"
|
|
87
89
|
? err.message
|
|
88
|
-
: "
|
|
90
|
+
: t("detailPage.references.saveFailed"),
|
|
89
91
|
);
|
|
90
92
|
},
|
|
91
93
|
});
|
|
@@ -106,7 +108,7 @@ export function DetailPageReferencesPanel({
|
|
|
106
108
|
) : (
|
|
107
109
|
<BookmarkPlus aria-hidden="true" size={18} />
|
|
108
110
|
)}
|
|
109
|
-
<span className="text-[11px]"
|
|
111
|
+
<span className="text-[11px]">{t("detailPage.references.saveThis")}</span>
|
|
110
112
|
</button>
|
|
111
113
|
) : null}
|
|
112
114
|
|
|
@@ -119,18 +121,15 @@ export function DetailPageReferencesPanel({
|
|
|
119
121
|
<Loader2 aria-hidden="true" className="animate-spin" size={22} />
|
|
120
122
|
</div>
|
|
121
123
|
) : !activeBrand ? (
|
|
122
|
-
<p className="text-xs text-dpe-ink-400"
|
|
124
|
+
<p className="text-xs text-dpe-ink-400">{t("detailPage.references.pickBrand")}</p>
|
|
123
125
|
) : listQuery.error ? (
|
|
124
126
|
<p className="text-xs font-dpe-medium text-dpe-danger-600">
|
|
125
|
-
|
|
127
|
+
{t("detailPage.references.loadFailed")}
|
|
126
128
|
</p>
|
|
127
129
|
) : groups.length === 0 ? (
|
|
128
130
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-dpe-ink-400">
|
|
129
131
|
<ImageOff aria-hidden="true" size={22} />
|
|
130
|
-
<p className="text-xs">
|
|
131
|
-
아직 저장한 레퍼런스가 없어요. 마음에 드는 상세페이지를 저장해 두면 다음
|
|
132
|
-
상품에서 바로 참고할 수 있어요.
|
|
133
|
-
</p>
|
|
132
|
+
<p className="text-xs">{t("detailPage.references.empty")}</p>
|
|
134
133
|
</div>
|
|
135
134
|
) : (
|
|
136
135
|
<div className="space-y-4 overflow-y-auto">
|
|
@@ -138,7 +137,9 @@ export function DetailPageReferencesPanel({
|
|
|
138
137
|
<section key={group.key}>
|
|
139
138
|
<h3 className="mb-1.5 truncate text-[11px] font-dpe-medium text-dpe-ink-600">
|
|
140
139
|
{group.name}
|
|
141
|
-
<span className="ml-1 text-dpe-ink-400">
|
|
140
|
+
<span className="ml-1 text-dpe-ink-400">
|
|
141
|
+
{t("detailPage.references.screenCount", { count: group.items.length })}
|
|
142
|
+
</span>
|
|
142
143
|
</h3>
|
|
143
144
|
<div className="grid grid-cols-2 gap-2">
|
|
144
145
|
{group.items.map((item) => (
|
|
@@ -90,7 +90,7 @@ export function EditUsageBadge({
|
|
|
90
90
|
if (unlimited) {
|
|
91
91
|
return (
|
|
92
92
|
<span
|
|
93
|
-
className="ml-auto text-[11px] tabular-nums text-
|
|
93
|
+
className="ml-auto text-[11px] tabular-nums text-dpe-ink-500"
|
|
94
94
|
title={t("detailPage.editQuota.unlimitedTitle")}
|
|
95
95
|
>
|
|
96
96
|
{t("detailPage.editQuota.usedUnlimited", { used })}
|
|
@@ -104,10 +104,10 @@ export function EditUsageBadge({
|
|
|
104
104
|
className={
|
|
105
105
|
"ml-auto text-[11px] tabular-nums " +
|
|
106
106
|
(remaining === 0
|
|
107
|
-
? "text-
|
|
107
|
+
? "text-dpe-danger-600"
|
|
108
108
|
: warn
|
|
109
109
|
? "text-dpe-warn-500"
|
|
110
|
-
: "text-
|
|
110
|
+
: "text-dpe-ink-500")
|
|
111
111
|
}
|
|
112
112
|
title={t("detailPage.editQuota.remainingTitle", { remaining })}
|
|
113
113
|
>
|
|
@@ -130,17 +130,17 @@ export function EditQuotaBlock({
|
|
|
130
130
|
: t("detailPage.editQuota.textEdit");
|
|
131
131
|
return (
|
|
132
132
|
<div className="flex flex-col items-center gap-2 px-4 py-4 text-center">
|
|
133
|
-
<p className="text-[13px] font-dpe-medium text-
|
|
133
|
+
<p className="text-[13px] font-dpe-medium text-dpe-ink-900">
|
|
134
134
|
{t("detailPage.editQuota.quotaExhausted", { label })}
|
|
135
135
|
</p>
|
|
136
|
-
<p className="text-[11px] leading-4 text-
|
|
136
|
+
<p className="text-[11px] leading-4 text-dpe-ink-500">
|
|
137
137
|
{t("detailPage.editQuota.quotaHint")}
|
|
138
138
|
</p>
|
|
139
139
|
<button
|
|
140
140
|
type="button"
|
|
141
141
|
onClick={onBuyMore}
|
|
142
142
|
disabled={!onBuyMore}
|
|
143
|
-
className="mt-1 inline-flex items-center gap-1.5 rounded-dpe-lg bg-
|
|
143
|
+
className="mt-1 inline-flex items-center gap-1.5 rounded-dpe-lg bg-dpe-ink-900 px-3 py-1.5 text-xs font-dpe-medium text-dpe-on-accent disabled:opacity-40"
|
|
144
144
|
>
|
|
145
145
|
<Sparkles size={13} />
|
|
146
146
|
{t("detailPage.editQuota.buyCredits")}
|
|
@@ -38,18 +38,18 @@ export function progressPercent(progress: number): number {
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* 밝은 배경 패널(카피·SVG 프롬프트 편집)의 하단에 얹는 얇은 프로그레스 라인.
|
|
41
|
-
*
|
|
42
|
-
* 하며, 이 요소는 컨테이너 하단에 절대배치된다.
|
|
41
|
+
* 색은 AI 표식 토큰(`dpe-ai`)이다 — 이 선이 도는 동안 일어나는 일이 생성이기
|
|
42
|
+
* 때문이다. 부모는 `relative`여야 하며, 이 요소는 컨테이너 하단에 절대배치된다.
|
|
43
43
|
*/
|
|
44
44
|
export function GenerationProgressLine({ progress }: { progress: number }) {
|
|
45
45
|
const pct = progressPercent(progress);
|
|
46
46
|
return (
|
|
47
47
|
<span
|
|
48
48
|
aria-hidden="true"
|
|
49
|
-
className="pointer-events-none absolute inset-x-0 bottom-0 h-[3px] bg-
|
|
49
|
+
className="pointer-events-none absolute inset-x-0 bottom-0 h-[3px] bg-dpe-ai/15"
|
|
50
50
|
>
|
|
51
51
|
<span
|
|
52
|
-
className="block h-full bg-
|
|
52
|
+
className="block h-full bg-dpe-ai"
|
|
53
53
|
style={{ width: `${pct}%`, transition: "width 200ms ease-out" }}
|
|
54
54
|
/>
|
|
55
55
|
</span>
|
|
@@ -106,10 +106,10 @@ export function GroupPromptEditPanel({
|
|
|
106
106
|
const badgeLimit = hasText ? textLimit : svgLimit;
|
|
107
107
|
|
|
108
108
|
return (
|
|
109
|
-
<div className="flex flex-col overflow-hidden rounded-dpe-xl border border-
|
|
110
|
-
<div className="flex items-center gap-1.5 border-b border-
|
|
111
|
-
<Sparkles size={13} className="text-
|
|
112
|
-
<span className="text-xs font-dpe-medium text-
|
|
109
|
+
<div className="flex flex-col overflow-hidden rounded-dpe-xl border border-dpe-ink-200 bg-dpe-surface">
|
|
110
|
+
<div className="flex items-center gap-1.5 border-b border-dpe-ink-200 px-3 py-2">
|
|
111
|
+
<Sparkles size={13} className="text-dpe-ai" />
|
|
112
|
+
<span className="text-xs font-dpe-medium text-dpe-ink-900">프롬프트로 편집</span>
|
|
113
113
|
{unlimited || badgeLimit > 0 ? (
|
|
114
114
|
<EditUsageBadge used={badgeUsed} limit={badgeLimit} unlimited={unlimited} />
|
|
115
115
|
) : null}
|
|
@@ -132,13 +132,13 @@ export function GroupPromptEditPanel({
|
|
|
132
132
|
busy ? "수정 중…" : '어떻게 바꿀까요? (예: "더 힘있게", "톤을 통일해서")'
|
|
133
133
|
}
|
|
134
134
|
disabled={busy}
|
|
135
|
-
className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-
|
|
135
|
+
className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-dpe-ink-900 outline-none placeholder:text-dpe-ink-400 disabled:opacity-60"
|
|
136
136
|
/>
|
|
137
137
|
<button
|
|
138
138
|
type="button"
|
|
139
139
|
onClick={send}
|
|
140
140
|
disabled={busy || !input.trim()}
|
|
141
|
-
className="grid h-8 w-8 shrink-0 place-items-center rounded-dpe-lg bg-
|
|
141
|
+
className="grid h-8 w-8 shrink-0 place-items-center rounded-dpe-lg bg-dpe-ink-900 text-dpe-on-accent disabled:opacity-40"
|
|
142
142
|
aria-label="프롬프트로 수정"
|
|
143
143
|
>
|
|
144
144
|
{busy ? (
|
|
@@ -97,10 +97,10 @@ export function PromptEditPanel({
|
|
|
97
97
|
}, [api, toast, input, busy, blocked, generatedId, slotRole, currentText, maxLength, renderKind, onApplied, onUsage, t]);
|
|
98
98
|
|
|
99
99
|
return (
|
|
100
|
-
<div className="relative flex flex-col overflow-hidden rounded-dpe-xl border border-
|
|
101
|
-
<div className="flex items-center gap-1.5 border-b border-
|
|
102
|
-
<Sparkles size={13} className="text-
|
|
103
|
-
<span className="text-xs font-dpe-medium text-
|
|
100
|
+
<div className="relative flex flex-col overflow-hidden rounded-dpe-xl border border-dpe-ink-200 bg-dpe-surface">
|
|
101
|
+
<div className="flex items-center gap-1.5 border-b border-dpe-ink-200 px-3 py-2">
|
|
102
|
+
<Sparkles size={13} className="text-dpe-ai" />
|
|
103
|
+
<span className="text-xs font-dpe-medium text-dpe-ink-900">{t("detailPage.promptEdit.header")}</span>
|
|
104
104
|
{unlimited || editLimit > 0 ? (
|
|
105
105
|
<EditUsageBadge used={editsUsed} limit={editLimit} unlimited={unlimited} />
|
|
106
106
|
) : null}
|
|
@@ -123,13 +123,13 @@ export function PromptEditPanel({
|
|
|
123
123
|
busy ? t("detailPage.promptEdit.editing") : t("detailPage.promptEdit.textPlaceholder")
|
|
124
124
|
}
|
|
125
125
|
disabled={busy}
|
|
126
|
-
className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-
|
|
126
|
+
className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-dpe-ink-900 outline-none placeholder:text-dpe-ink-400 disabled:opacity-60"
|
|
127
127
|
/>
|
|
128
128
|
<button
|
|
129
129
|
type="button"
|
|
130
130
|
onClick={send}
|
|
131
131
|
disabled={busy || !input.trim()}
|
|
132
|
-
className="grid h-8 w-8 shrink-0 place-items-center rounded-dpe-lg bg-
|
|
132
|
+
className="grid h-8 w-8 shrink-0 place-items-center rounded-dpe-lg bg-dpe-ink-900 text-dpe-on-accent disabled:opacity-40"
|
|
133
133
|
aria-label={t("detailPage.promptEdit.send")}
|
|
134
134
|
>
|
|
135
135
|
{busy ? (
|
|
@@ -122,10 +122,10 @@ export function SpecPromptEditPanel({
|
|
|
122
122
|
]);
|
|
123
123
|
|
|
124
124
|
return (
|
|
125
|
-
<div className="relative flex flex-col overflow-hidden rounded-dpe-xl border border-
|
|
126
|
-
<div className="flex items-center gap-1.5 border-b border-
|
|
127
|
-
<Sparkles size={13} className="text-
|
|
128
|
-
<span className="text-xs font-dpe-medium text-
|
|
125
|
+
<div className="relative flex flex-col overflow-hidden rounded-dpe-xl border border-dpe-ink-200 bg-dpe-surface">
|
|
126
|
+
<div className="flex items-center gap-1.5 border-b border-dpe-ink-200 px-3 py-2">
|
|
127
|
+
<Sparkles size={13} className="text-dpe-ai" />
|
|
128
|
+
<span className="text-xs font-dpe-medium text-dpe-ink-900">
|
|
129
129
|
{t("detailPage.promptEdit.header")}
|
|
130
130
|
</span>
|
|
131
131
|
{unlimited || editLimit > 0 ? (
|
|
@@ -152,13 +152,13 @@ export function SpecPromptEditPanel({
|
|
|
152
152
|
: t(`detailPage.specPromptEdit.placeholder.${specKind}`)
|
|
153
153
|
}
|
|
154
154
|
disabled={busy}
|
|
155
|
-
className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-
|
|
155
|
+
className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-dpe-ink-900 outline-none placeholder:text-dpe-ink-400 disabled:opacity-60"
|
|
156
156
|
/>
|
|
157
157
|
<button
|
|
158
158
|
type="button"
|
|
159
159
|
onClick={send}
|
|
160
160
|
disabled={busy || !input.trim()}
|
|
161
|
-
className="grid h-8 w-8 shrink-0 place-items-center rounded-dpe-lg bg-
|
|
161
|
+
className="grid h-8 w-8 shrink-0 place-items-center rounded-dpe-lg bg-dpe-ink-900 text-dpe-on-accent disabled:opacity-40"
|
|
162
162
|
aria-label={t("detailPage.promptEdit.send")}
|
|
163
163
|
>
|
|
164
164
|
{busy ? (
|
|
@@ -170,7 +170,7 @@ export function SpecPromptEditPanel({
|
|
|
170
170
|
</div>
|
|
171
171
|
)}
|
|
172
172
|
{busy ? <GenerationProgressLine progress={progress} /> : null}
|
|
173
|
-
<p className="px-3 pb-2 text-[11px] leading-relaxed text-
|
|
173
|
+
<p className="px-3 pb-2 text-[11px] leading-relaxed text-dpe-ink-500">
|
|
174
174
|
{t("detailPage.specPromptEdit.hint")}
|
|
175
175
|
</p>
|
|
176
176
|
</div>
|
|
@@ -90,10 +90,10 @@ export function SvgPromptEditPanel({
|
|
|
90
90
|
}, [api, toast, input, busy, blocked, generatedId, slotRole, currentSvg, onApplied, onUsage, t]);
|
|
91
91
|
|
|
92
92
|
return (
|
|
93
|
-
<div className="relative flex flex-col overflow-hidden rounded-dpe-xl border border-
|
|
94
|
-
<div className="flex items-center gap-1.5 border-b border-
|
|
95
|
-
<Sparkles size={13} className="text-
|
|
96
|
-
<span className="text-xs font-dpe-medium text-
|
|
93
|
+
<div className="relative flex flex-col overflow-hidden rounded-dpe-xl border border-dpe-ink-200 bg-dpe-surface">
|
|
94
|
+
<div className="flex items-center gap-1.5 border-b border-dpe-ink-200 px-3 py-2">
|
|
95
|
+
<Sparkles size={13} className="text-dpe-ai" />
|
|
96
|
+
<span className="text-xs font-dpe-medium text-dpe-ink-900">{t("detailPage.promptEdit.header")}</span>
|
|
97
97
|
{unlimited || editLimit > 0 ? (
|
|
98
98
|
<EditUsageBadge used={editsUsed} limit={editLimit} unlimited={unlimited} />
|
|
99
99
|
) : null}
|
|
@@ -116,13 +116,13 @@ export function SvgPromptEditPanel({
|
|
|
116
116
|
busy ? t("detailPage.promptEdit.editing") : t("detailPage.svgPromptEdit.placeholder")
|
|
117
117
|
}
|
|
118
118
|
disabled={busy}
|
|
119
|
-
className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-
|
|
119
|
+
className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-dpe-ink-900 outline-none placeholder:text-dpe-ink-400 disabled:opacity-60"
|
|
120
120
|
/>
|
|
121
121
|
<button
|
|
122
122
|
type="button"
|
|
123
123
|
onClick={send}
|
|
124
124
|
disabled={busy || !input.trim()}
|
|
125
|
-
className="grid h-8 w-8 shrink-0 place-items-center rounded-dpe-lg bg-
|
|
125
|
+
className="grid h-8 w-8 shrink-0 place-items-center rounded-dpe-lg bg-dpe-ink-900 text-dpe-on-accent disabled:opacity-40"
|
|
126
126
|
aria-label={t("detailPage.promptEdit.send")}
|
|
127
127
|
>
|
|
128
128
|
{busy ? (
|
package/components/ui/button.tsx
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 편집기 버튼.
|
|
3
|
+
*
|
|
4
|
+
* 색은 **편집기 토큰(`dpe-*`)만** 쓴다. `bg-primary` · `text-primary-foreground` 는
|
|
5
|
+
* 소비자 앱의 CSS 변수를 읽는 이름이고, leviosa-agency 는 `--color-primary-foreground`
|
|
6
|
+
* 를 안 둔다. 그래서 글자색 클래스가 한 줄도 안 구워졌고, 글자가 본문
|
|
7
|
+
* (`--color-fg: #1a1a1a`)을 물려받아 호출부가 덮은 배경(`bg-dpe-ink-900`, 같은 앱에서
|
|
8
|
+
* `#1a1a1a`)과 정확히 같은 값이 됐다 — 다운로드 팝오버의 저장 버튼이 검은 판때기로
|
|
9
|
+
* 보인 이유다.
|
|
10
|
+
*
|
|
11
|
+
* 테두리도 같다. 색 없는 `border` 는 currentColor 라, 글자색이 어두우면 테두리가
|
|
12
|
+
* 통째로 먹으로 나온다. 색을 쓰는 자리마다 토큰을 적는다.
|
|
13
|
+
*/
|
|
1
14
|
import * as React from "react"
|
|
2
15
|
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
16
|
import { Slot } from "radix-ui"
|
|
@@ -5,20 +18,20 @@ import { Slot } from "radix-ui"
|
|
|
5
18
|
import { cn } from "../../lib/utils"
|
|
6
19
|
|
|
7
20
|
const buttonVariants = cva(
|
|
8
|
-
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-dpe-md text-sm font-dpe-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-
|
|
21
|
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-dpe-md text-sm font-dpe-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-dpe-ink-400 focus-visible:ring-dpe-ink-300/50 focus-visible:ring-[3px] aria-invalid:ring-dpe-danger-500/20 dark:aria-invalid:ring-dpe-danger-500/40 aria-invalid:border-dpe-danger-500",
|
|
9
22
|
{
|
|
10
23
|
variants: {
|
|
11
24
|
variant: {
|
|
12
|
-
default: "bg-
|
|
25
|
+
default: "bg-dpe-ink-900 text-dpe-on-accent hover:bg-dpe-ink-900/90",
|
|
13
26
|
destructive:
|
|
14
|
-
"bg-
|
|
27
|
+
"bg-dpe-danger-500 text-dpe-on-accent hover:bg-dpe-danger-500/90 focus-visible:ring-dpe-danger-500/20 dark:focus-visible:ring-dpe-danger-500/40 dark:bg-dpe-danger-500/60",
|
|
15
28
|
outline:
|
|
16
|
-
"border bg-
|
|
29
|
+
"border border-dpe-ink-200 bg-dpe-surface text-dpe-ink-900 shadow-xs hover:bg-dpe-ink-100 dark:bg-dpe-ink-100/30 dark:hover:bg-dpe-ink-100/50",
|
|
17
30
|
secondary:
|
|
18
|
-
"bg-
|
|
31
|
+
"bg-dpe-ink-100 text-dpe-ink-900 hover:bg-dpe-ink-100/80",
|
|
19
32
|
ghost:
|
|
20
|
-
"hover:bg-
|
|
21
|
-
link: "text-
|
|
33
|
+
"hover:bg-dpe-ink-100 hover:text-dpe-ink-900 dark:hover:bg-dpe-ink-100/50",
|
|
34
|
+
link: "text-dpe-ink-900 underline-offset-4 hover:underline",
|
|
22
35
|
},
|
|
23
36
|
size: {
|
|
24
37
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
package/components/ui/dialog.tsx
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* 편집기 모달. 포털이 body 로 나가므로 `data-dpe-root` 를 다시 달고(아래), 판의
|
|
5
|
+
* 글자색과 테두리색은 물려받지 않고 적는다 — 색 없는 `border` 는 currentColor 다.
|
|
6
|
+
* 이유는 select.tsx 주석에 있다.
|
|
7
|
+
*/
|
|
3
8
|
import * as React from "react";
|
|
4
9
|
import { XIcon } from "lucide-react";
|
|
5
10
|
import { Dialog as DialogPrimitive } from "radix-ui";
|
|
@@ -63,8 +68,9 @@ function DialogContent({
|
|
|
63
68
|
<DialogOverlay />
|
|
64
69
|
<DialogPrimitive.Content
|
|
65
70
|
data-slot="dialog-content"
|
|
71
|
+
data-dpe-root=""
|
|
66
72
|
className={cn(
|
|
67
|
-
"bg-
|
|
73
|
+
"bg-dpe-surface text-dpe-ink-900 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[115] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-dpe-lg border border-dpe-ink-200 p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
|
68
74
|
className,
|
|
69
75
|
)}
|
|
70
76
|
onOpenAutoFocus={(event) => {
|
|
@@ -79,7 +85,7 @@ function DialogContent({
|
|
|
79
85
|
{showCloseButton && (
|
|
80
86
|
<DialogPrimitive.Close
|
|
81
87
|
data-slot="dialog-close"
|
|
82
|
-
className="ring-offset-
|
|
88
|
+
className="ring-offset-dpe-surface focus:ring-dpe-ink-300 data-[state=open]:bg-dpe-ink-100 data-[state=open]:text-dpe-ink-500 absolute top-3 right-3 flex size-11 items-center justify-center rounded-full opacity-70 transition-opacity hover:opacity-100 hover:bg-dpe-ink-100 active:bg-dpe-ink-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5"
|
|
83
89
|
>
|
|
84
90
|
<XIcon />
|
|
85
91
|
<span className="sr-only">Close</span>
|
|
@@ -147,7 +153,7 @@ function DialogDescription({
|
|
|
147
153
|
return (
|
|
148
154
|
<DialogPrimitive.Description
|
|
149
155
|
data-slot="dialog-description"
|
|
150
|
-
className={cn("text-
|
|
156
|
+
className={cn("text-dpe-ink-500 text-sm", className)}
|
|
151
157
|
{...props}
|
|
152
158
|
/>
|
|
153
159
|
);
|
package/components/ui/select.tsx
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Radix Select 위에 얹은 드롭다운.
|
|
5
|
+
*
|
|
6
|
+
* 색은 **편집기 토큰(`dpe-*`)만** 쓴다. shadcn 원본이 달고 온 `bg-popover` ·
|
|
7
|
+
* `text-popover-foreground` · `border-input` · `bg-accent` · `text-muted-foreground`
|
|
8
|
+
* 는 전부 소비자 앱의 팔레트를 읽는 이름이라, 이 패키지가 그 값을 정할 방법이 없다.
|
|
9
|
+
* leviosa-agency 는 `--color-popover` 를 아예 안 두므로 그 유틸리티가 한 줄도 안
|
|
10
|
+
* 구워졌고, 목록 판이 **투명해져** 뒤의 라벨과 글자가 겹쳐 보였다.
|
|
11
|
+
*
|
|
12
|
+
* 글자색은 물려받지 않고 판마다 적는다(`text-dpe-ink-900`). 이 버그가 아픈 이유가
|
|
13
|
+
* 정확히 "안 적으면 바깥 것을 물려받는다" 였다 — 판은 토큰으로 칠하면서 글자를
|
|
14
|
+
* 물려받게 두면 갈아입힌 앱에서 같은 자리에 같은 일이 다시 난다.
|
|
15
|
+
*/
|
|
3
16
|
import * as React from "react"
|
|
4
17
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
|
5
18
|
import { Select as SelectPrimitive } from "radix-ui"
|
|
@@ -37,7 +50,7 @@ function SelectTrigger({
|
|
|
37
50
|
data-slot="select-trigger"
|
|
38
51
|
data-size={size}
|
|
39
52
|
className={cn(
|
|
40
|
-
"border-
|
|
53
|
+
"border-dpe-ink-200 text-dpe-ink-900 data-[placeholder]:text-dpe-ink-400 [&_svg:not([class*='text-'])]:text-dpe-ink-500 focus-visible:border-dpe-ink-400 focus-visible:ring-dpe-ink-300/50 aria-invalid:ring-dpe-danger-500/20 dark:aria-invalid:ring-dpe-danger-500/40 aria-invalid:border-dpe-danger-500 dark:bg-dpe-ink-100/30 dark:hover:bg-dpe-ink-100/50 flex w-fit items-center justify-between gap-2 rounded-dpe-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
41
54
|
className
|
|
42
55
|
)}
|
|
43
56
|
{...props}
|
|
@@ -53,16 +66,21 @@ function SelectTrigger({
|
|
|
53
66
|
function SelectContent({
|
|
54
67
|
className,
|
|
55
68
|
children,
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
// 트리거 **아래**에 붙인다. Radix 기본값(`item-aligned`)은 고른 항목이 트리거를 덮게
|
|
70
|
+
// 얹어서, 목록이 트리거 위로 겹쳐 뜬다.
|
|
71
|
+
position = "popper",
|
|
72
|
+
align = "start",
|
|
58
73
|
...props
|
|
59
74
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
|
60
75
|
return (
|
|
61
76
|
<SelectPrimitive.Portal>
|
|
62
77
|
<SelectPrimitive.Content
|
|
78
|
+
// 포털이 body 로 나가 편집기 뿌리 밖에 선다 — 딱지를 다시 달아야 소비자 앱이
|
|
79
|
+
// `[data-dpe-root]` 로 좁혀 둔 토큰이 이 목록에도 먹는다.
|
|
80
|
+
data-dpe-root=""
|
|
63
81
|
data-slot="select-content"
|
|
64
82
|
className={cn(
|
|
65
|
-
"bg-
|
|
83
|
+
"bg-dpe-surface text-dpe-ink-900 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-dpe-md border border-dpe-ink-200 shadow-md",
|
|
66
84
|
position === "popper" &&
|
|
67
85
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
|
68
86
|
className
|
|
@@ -94,7 +112,7 @@ function SelectLabel({
|
|
|
94
112
|
return (
|
|
95
113
|
<SelectPrimitive.Label
|
|
96
114
|
data-slot="select-label"
|
|
97
|
-
className={cn("text-
|
|
115
|
+
className={cn("text-dpe-ink-500 px-2 py-1.5 text-xs", className)}
|
|
98
116
|
{...props}
|
|
99
117
|
/>
|
|
100
118
|
)
|
|
@@ -109,7 +127,7 @@ function SelectItem({
|
|
|
109
127
|
<SelectPrimitive.Item
|
|
110
128
|
data-slot="select-item"
|
|
111
129
|
className={cn(
|
|
112
|
-
"
|
|
130
|
+
"text-dpe-ink-900 focus:bg-dpe-ink-100 [&_svg:not([class*='text-'])]:text-dpe-ink-500 relative flex w-full cursor-default items-center gap-2 rounded-dpe-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
|
113
131
|
className
|
|
114
132
|
)}
|
|
115
133
|
{...props}
|
|
@@ -134,7 +152,7 @@ function SelectSeparator({
|
|
|
134
152
|
return (
|
|
135
153
|
<SelectPrimitive.Separator
|
|
136
154
|
data-slot="select-separator"
|
|
137
|
-
className={cn("bg-
|
|
155
|
+
className={cn("bg-dpe-ink-200 pointer-events-none -mx-1 my-1 h-px", className)}
|
|
138
156
|
{...props}
|
|
139
157
|
/>
|
|
140
158
|
)
|
package/components/ui/slider.tsx
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* 해상도 배율 같은 값 슬라이더. 궤도·채움·손잡이 전부 편집기 토큰이다 — 원본의
|
|
5
|
+
* `bg-muted` · `bg-primary` 는 소비자 앱의 이름이라, 그 앱이 안 두면 채운 구간이
|
|
6
|
+
* 통째로 안 보인다(select.tsx 주석 참고).
|
|
7
|
+
*/
|
|
3
8
|
import * as React from "react"
|
|
4
9
|
import { Slider as SliderPrimitive } from "radix-ui"
|
|
5
10
|
|
|
@@ -39,13 +44,13 @@ function Slider({
|
|
|
39
44
|
<SliderPrimitive.Track
|
|
40
45
|
data-slot="slider-track"
|
|
41
46
|
className={cn(
|
|
42
|
-
"bg-
|
|
47
|
+
"bg-dpe-ink-200 relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
|
43
48
|
)}
|
|
44
49
|
>
|
|
45
50
|
<SliderPrimitive.Range
|
|
46
51
|
data-slot="slider-range"
|
|
47
52
|
className={cn(
|
|
48
|
-
"bg-
|
|
53
|
+
"bg-dpe-ink-900 absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
|
49
54
|
)}
|
|
50
55
|
/>
|
|
51
56
|
</SliderPrimitive.Track>
|
|
@@ -53,7 +58,7 @@ function Slider({
|
|
|
53
58
|
<SliderPrimitive.Thumb
|
|
54
59
|
data-slot="slider-thumb"
|
|
55
60
|
key={index}
|
|
56
|
-
className="border-
|
|
61
|
+
className="border-dpe-ink-900 ring-dpe-ink-300/50 block size-4 shrink-0 rounded-full border bg-dpe-surface shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
|
57
62
|
/>
|
|
58
63
|
))}
|
|
59
64
|
</SliderPrimitive.Root>
|
package/components/ui/switch.tsx
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* 켬/끔 스위치. 켠 색은 먹(`dpe-ink-900`), 끈 색은 `dpe-ink-300` 이다.
|
|
5
|
+
*
|
|
6
|
+
* 끈 색이 `ink-100` 이면 흰 패널(`dpe-surface`) 위에서 궤도가 거의 안 보인다 —
|
|
7
|
+
* 손잡이도 흰색이고 테두리는 투명이라, 꺼진 스위치가 통째로 사라진다. 한 단계
|
|
8
|
+
* 내려서 꺼짐이 꺼짐으로 읽히게 한다.
|
|
9
|
+
*/
|
|
3
10
|
import * as React from "react"
|
|
4
11
|
import { Switch as SwitchPrimitive } from "radix-ui"
|
|
5
12
|
|
|
@@ -17,7 +24,7 @@ function Switch({
|
|
|
17
24
|
data-slot="switch"
|
|
18
25
|
data-size={size}
|
|
19
26
|
className={cn(
|
|
20
|
-
"peer data-[state=checked]:bg-
|
|
27
|
+
"peer data-[state=checked]:bg-dpe-ink-900 data-[state=unchecked]:bg-dpe-ink-300 focus-visible:border-dpe-ink-400 focus-visible:ring-dpe-ink-300/50 dark:data-[state=unchecked]:bg-dpe-ink-100/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
|
|
21
28
|
className
|
|
22
29
|
)}
|
|
23
30
|
{...props}
|
|
@@ -25,7 +32,7 @@ function Switch({
|
|
|
25
32
|
<SwitchPrimitive.Thumb
|
|
26
33
|
data-slot="switch-thumb"
|
|
27
34
|
className={cn(
|
|
28
|
-
"bg-
|
|
35
|
+
"bg-dpe-surface dark:data-[state=unchecked]:bg-dpe-ink-900 dark:data-[state=checked]:bg-dpe-on-accent pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
|
29
36
|
)}
|
|
30
37
|
/>
|
|
31
38
|
</SwitchPrimitive.Root>
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* 말풍선 도움말. 어두운 판(`dpe-ink-900`) 위 `dpe-on-accent` 글자다.
|
|
5
|
+
*
|
|
6
|
+
* `text-background` 는 소비자 앱의 이름이라, 그 앱이 안 두면 클래스가 아예 안 구워지고
|
|
7
|
+
* 글자색이 물려받은 값(먹)으로 남는다 — 어두운 판 위 어두운 글자. 판만 토큰으로
|
|
8
|
+
* 옮기고 글자를 안 옮기면 오히려 원본보다 안 보인다.
|
|
9
|
+
*/
|
|
3
10
|
import * as React from "react"
|
|
4
11
|
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
|
5
12
|
|
|
@@ -39,16 +46,17 @@ function TooltipContent({
|
|
|
39
46
|
return (
|
|
40
47
|
<TooltipPrimitive.Portal>
|
|
41
48
|
<TooltipPrimitive.Content
|
|
49
|
+
data-dpe-root=""
|
|
42
50
|
data-slot="tooltip-content"
|
|
43
51
|
sideOffset={sideOffset}
|
|
44
52
|
className={cn(
|
|
45
|
-
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-dpe-md bg-
|
|
53
|
+
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-dpe-md bg-dpe-ink-900 px-3 py-1.5 text-xs text-balance text-dpe-on-accent fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
|
46
54
|
className
|
|
47
55
|
)}
|
|
48
56
|
{...props}
|
|
49
57
|
>
|
|
50
58
|
{children}
|
|
51
|
-
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-
|
|
59
|
+
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-dpe-ink-900 fill-dpe-ink-900" />
|
|
52
60
|
</TooltipPrimitive.Content>
|
|
53
61
|
</TooltipPrimitive.Portal>
|
|
54
62
|
)
|
package/i18n/en.json
CHANGED
|
@@ -888,6 +888,16 @@
|
|
|
888
888
|
"texts": "Rewrite every text in this group with one prompt.",
|
|
889
889
|
"shapes": "Redraw every shape in this group with one prompt.",
|
|
890
890
|
"both": "Rewrite the texts and redraw the shapes in this group with one prompt."
|
|
891
|
+
},
|
|
892
|
+
"references": {
|
|
893
|
+
"untitled": "Detail page",
|
|
894
|
+
"saveThis": "Save this detail page as a reference",
|
|
895
|
+
"saved": "Saved as a reference ({{count}} screens).",
|
|
896
|
+
"saveFailed": "Could not save it as a reference.",
|
|
897
|
+
"pickBrand": "Pick a brand first.",
|
|
898
|
+
"loadFailed": "Could not load your references.",
|
|
899
|
+
"empty": "No saved references yet. Save a detail page you like and it is ready to reference on your next product.",
|
|
900
|
+
"screenCount": "{{count}} screens"
|
|
891
901
|
}
|
|
892
902
|
}
|
|
893
903
|
},
|
package/i18n/ko.json
CHANGED
|
@@ -888,6 +888,16 @@
|
|
|
888
888
|
"texts": "그룹 안 텍스트를 한 번에 프롬프트로 다시 씁니다.",
|
|
889
889
|
"shapes": "그룹 안 도형을 프롬프트로 함께 수정합니다.",
|
|
890
890
|
"both": "그룹 안 텍스트와 도형을 한 번에 프롬프트로 수정합니다."
|
|
891
|
+
},
|
|
892
|
+
"references": {
|
|
893
|
+
"untitled": "상세페이지",
|
|
894
|
+
"saveThis": "이 상세페이지를 레퍼런스로 저장",
|
|
895
|
+
"saved": "레퍼런스로 저장했어요 (화면 {{count}}장).",
|
|
896
|
+
"saveFailed": "레퍼런스로 저장하지 못했어요.",
|
|
897
|
+
"pickBrand": "브랜드를 먼저 골라 주세요.",
|
|
898
|
+
"loadFailed": "레퍼런스를 불러오지 못했어요.",
|
|
899
|
+
"empty": "아직 저장한 레퍼런스가 없어요. 마음에 드는 상세페이지를 저장해 두면 다음 상품에서 바로 참고할 수 있어요.",
|
|
900
|
+
"screenCount": "{{count}}장"
|
|
891
901
|
}
|
|
892
902
|
}
|
|
893
903
|
},
|
|
@@ -10,6 +10,13 @@ export type DetailPageEditorProfile = {
|
|
|
10
10
|
};
|
|
11
11
|
maxPages: number;
|
|
12
12
|
exports: readonly DetailPageEditorFormat[];
|
|
13
|
+
/**
|
|
14
|
+
* 다운로드 팝오버에 "등록 플랫폼"을 물어볼지. 그 답은 파일명 접미사에만 쓰이고,
|
|
15
|
+
* 셀러가 상품을 어디에 올릴지 고르며 맥락을 잡으라고 있는 것이다. 캐러셀은
|
|
16
|
+
* 인스타그램 한 곳으로 나가므로 물어볼 것이 없다 — 고를 수 없는 선택지를
|
|
17
|
+
* 남겨 두면 접미사만 거짓말을 한다(`-naver` 붙은 인스타 이미지).
|
|
18
|
+
*/
|
|
19
|
+
registerPlatform: boolean;
|
|
13
20
|
wording: "section" | "plate";
|
|
14
21
|
};
|
|
15
22
|
|
|
@@ -17,13 +24,19 @@ const DETAIL_PAGE_PROFILE: DetailPageEditorProfile = {
|
|
|
17
24
|
page: { width: "document", height: "auto", fixed: false },
|
|
18
25
|
maxPages: Number.POSITIVE_INFINITY,
|
|
19
26
|
exports: ["png", "jpeg", "psd", "ai", "svg"],
|
|
27
|
+
registerPlatform: true,
|
|
20
28
|
wording: "section",
|
|
21
29
|
};
|
|
22
30
|
|
|
23
31
|
const CAROUSEL_PROFILE: DetailPageEditorProfile = {
|
|
24
32
|
page: { width: 1080, height: 1350, fixed: true },
|
|
25
33
|
maxPages: 10,
|
|
26
|
-
|
|
34
|
+
// 상세페이지와 같은 다섯 가지다. 한때 JPG 하나로 좁혀 뒀던 것은 인스타그램이
|
|
35
|
+
// JPG 로 받는다는 이유였는데, 내보낸 파일이 곧장 업로드로만 가는 것이 아니다 —
|
|
36
|
+
// 투명 배경을 살리려면 PNG 가, 다른 도구로 넘겨 손보려면 PSD·AI·SVG 가 필요하다.
|
|
37
|
+
// 첫 항목이 기본값이므로 JPG 를 앞에 둬서 지금까지의 기본 동작은 그대로 둔다.
|
|
38
|
+
exports: ["jpeg", "png", "psd", "ai", "svg"],
|
|
39
|
+
registerPlatform: false,
|
|
27
40
|
wording: "plate",
|
|
28
41
|
};
|
|
29
42
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leviosa-ai/detail-page-editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"woff2-encoder": "2.0.0"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@leviosa-ai/canvas": "0.
|
|
70
|
+
"@leviosa-ai/canvas": "0.5.0",
|
|
71
71
|
"@tanstack/react-query": "^5.100.3",
|
|
72
72
|
"konva": "^10.0.0",
|
|
73
73
|
"lucide-react": "^0.563.0 || ^1.0.0",
|