@leviosa-ai/detail-page-editor 0.11.8 → 0.12.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/cardnews/color-input.tsx +43 -151
- package/components/detail-page/canvas-context-menu.tsx +3 -0
- package/components/detail-page/detail-page-properties-panel.tsx +13 -8
- package/components/detail-page/detail-page-references-panel.tsx +13 -12
- package/components/ui/button.tsx +7 -7
- package/components/ui/dialog.tsx +4 -3
- package/components/ui/select.tsx +12 -7
- package/components/ui/slider.tsx +3 -3
- package/components/ui/switch.tsx +2 -2
- package/components/ui/tooltip.tsx +3 -2
- package/i18n/en.json +10 -0
- package/i18n/ko.json +10 -0
- 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
|
>
|
|
@@ -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) => (
|
package/components/ui/button.tsx
CHANGED
|
@@ -5,20 +5,20 @@ import { Slot } from "radix-ui"
|
|
|
5
5
|
import { cn } from "../../lib/utils"
|
|
6
6
|
|
|
7
7
|
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-
|
|
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-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
9
|
{
|
|
10
10
|
variants: {
|
|
11
11
|
variant: {
|
|
12
|
-
default: "bg-
|
|
12
|
+
default: "bg-dpe-ink-900 text-dpe-on-accent hover:bg-dpe-ink-900/90",
|
|
13
13
|
destructive:
|
|
14
|
-
"bg-
|
|
14
|
+
"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
15
|
outline:
|
|
16
|
-
"border bg-
|
|
16
|
+
"border bg-dpe-surface shadow-xs hover:bg-dpe-ink-100 hover:text-dpe-ink-900 dark:bg-dpe-ink-100/30 dark:border-dpe-ink-200 dark:hover:bg-dpe-ink-100/50",
|
|
17
17
|
secondary:
|
|
18
|
-
"bg-
|
|
18
|
+
"bg-dpe-ink-100 text-dpe-ink-900 hover:bg-dpe-ink-100/80",
|
|
19
19
|
ghost:
|
|
20
|
-
"hover:bg-
|
|
21
|
-
link: "text-
|
|
20
|
+
"hover:bg-dpe-ink-100 hover:text-dpe-ink-900 dark:hover:bg-dpe-ink-100/50",
|
|
21
|
+
link: "text-dpe-ink-900 underline-offset-4 hover:underline",
|
|
22
22
|
},
|
|
23
23
|
size: {
|
|
24
24
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
package/components/ui/dialog.tsx
CHANGED
|
@@ -63,8 +63,9 @@ function DialogContent({
|
|
|
63
63
|
<DialogOverlay />
|
|
64
64
|
<DialogPrimitive.Content
|
|
65
65
|
data-slot="dialog-content"
|
|
66
|
+
data-dpe-root=""
|
|
66
67
|
className={cn(
|
|
67
|
-
"bg-
|
|
68
|
+
"bg-dpe-surface 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 p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
|
68
69
|
className,
|
|
69
70
|
)}
|
|
70
71
|
onOpenAutoFocus={(event) => {
|
|
@@ -79,7 +80,7 @@ function DialogContent({
|
|
|
79
80
|
{showCloseButton && (
|
|
80
81
|
<DialogPrimitive.Close
|
|
81
82
|
data-slot="dialog-close"
|
|
82
|
-
className="ring-offset-
|
|
83
|
+
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
84
|
>
|
|
84
85
|
<XIcon />
|
|
85
86
|
<span className="sr-only">Close</span>
|
|
@@ -147,7 +148,7 @@ function DialogDescription({
|
|
|
147
148
|
return (
|
|
148
149
|
<DialogPrimitive.Description
|
|
149
150
|
data-slot="dialog-description"
|
|
150
|
-
className={cn("text-
|
|
151
|
+
className={cn("text-dpe-ink-500 text-sm", className)}
|
|
151
152
|
{...props}
|
|
152
153
|
/>
|
|
153
154
|
);
|
package/components/ui/select.tsx
CHANGED
|
@@ -37,7 +37,7 @@ function SelectTrigger({
|
|
|
37
37
|
data-slot="select-trigger"
|
|
38
38
|
data-size={size}
|
|
39
39
|
className={cn(
|
|
40
|
-
"border-
|
|
40
|
+
"border-dpe-ink-200 data-[placeholder]:text-dpe-ink-500 [&_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
41
|
className
|
|
42
42
|
)}
|
|
43
43
|
{...props}
|
|
@@ -53,16 +53,21 @@ function SelectTrigger({
|
|
|
53
53
|
function SelectContent({
|
|
54
54
|
className,
|
|
55
55
|
children,
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
// 트리거 **아래**에 붙인다. Radix 기본값(`item-aligned`)은 고른 항목이 트리거를 덮게
|
|
57
|
+
// 얹어서, 목록이 트리거 위로 겹쳐 뜬다.
|
|
58
|
+
position = "popper",
|
|
59
|
+
align = "start",
|
|
58
60
|
...props
|
|
59
61
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
|
60
62
|
return (
|
|
61
63
|
<SelectPrimitive.Portal>
|
|
62
64
|
<SelectPrimitive.Content
|
|
65
|
+
// 포털이 body 로 나가 편집기 뿌리 밖에 선다 — 딱지를 다시 달아야 소비자 앱이
|
|
66
|
+
// `[data-dpe-root]` 로 좁혀 둔 토큰이 이 목록에도 먹는다.
|
|
67
|
+
data-dpe-root=""
|
|
63
68
|
data-slot="select-content"
|
|
64
69
|
className={cn(
|
|
65
|
-
"bg-
|
|
70
|
+
"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
71
|
position === "popper" &&
|
|
67
72
|
"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
73
|
className
|
|
@@ -94,7 +99,7 @@ function SelectLabel({
|
|
|
94
99
|
return (
|
|
95
100
|
<SelectPrimitive.Label
|
|
96
101
|
data-slot="select-label"
|
|
97
|
-
className={cn("text-
|
|
102
|
+
className={cn("text-dpe-ink-500 px-2 py-1.5 text-xs", className)}
|
|
98
103
|
{...props}
|
|
99
104
|
/>
|
|
100
105
|
)
|
|
@@ -109,7 +114,7 @@ function SelectItem({
|
|
|
109
114
|
<SelectPrimitive.Item
|
|
110
115
|
data-slot="select-item"
|
|
111
116
|
className={cn(
|
|
112
|
-
"focus:bg-
|
|
117
|
+
"focus:bg-dpe-ink-100 focus:text-dpe-ink-900 [&_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
118
|
className
|
|
114
119
|
)}
|
|
115
120
|
{...props}
|
|
@@ -134,7 +139,7 @@ function SelectSeparator({
|
|
|
134
139
|
return (
|
|
135
140
|
<SelectPrimitive.Separator
|
|
136
141
|
data-slot="select-separator"
|
|
137
|
-
className={cn("bg-
|
|
142
|
+
className={cn("bg-dpe-ink-200 pointer-events-none -mx-1 my-1 h-px", className)}
|
|
138
143
|
{...props}
|
|
139
144
|
/>
|
|
140
145
|
)
|
package/components/ui/slider.tsx
CHANGED
|
@@ -39,13 +39,13 @@ function Slider({
|
|
|
39
39
|
<SliderPrimitive.Track
|
|
40
40
|
data-slot="slider-track"
|
|
41
41
|
className={cn(
|
|
42
|
-
"bg-
|
|
42
|
+
"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
43
|
)}
|
|
44
44
|
>
|
|
45
45
|
<SliderPrimitive.Range
|
|
46
46
|
data-slot="slider-range"
|
|
47
47
|
className={cn(
|
|
48
|
-
"bg-
|
|
48
|
+
"bg-dpe-ink-900 absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
|
49
49
|
)}
|
|
50
50
|
/>
|
|
51
51
|
</SliderPrimitive.Track>
|
|
@@ -53,7 +53,7 @@ function Slider({
|
|
|
53
53
|
<SliderPrimitive.Thumb
|
|
54
54
|
data-slot="slider-thumb"
|
|
55
55
|
key={index}
|
|
56
|
-
className="border-
|
|
56
|
+
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
57
|
/>
|
|
58
58
|
))}
|
|
59
59
|
</SliderPrimitive.Root>
|
package/components/ui/switch.tsx
CHANGED
|
@@ -17,7 +17,7 @@ function Switch({
|
|
|
17
17
|
data-slot="switch"
|
|
18
18
|
data-size={size}
|
|
19
19
|
className={cn(
|
|
20
|
-
"peer data-[state=checked]:bg-
|
|
20
|
+
"peer data-[state=checked]:bg-dpe-ink-900 data-[state=unchecked]:bg-dpe-ink-100 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
21
|
className
|
|
22
22
|
)}
|
|
23
23
|
{...props}
|
|
@@ -25,7 +25,7 @@ function Switch({
|
|
|
25
25
|
<SwitchPrimitive.Thumb
|
|
26
26
|
data-slot="switch-thumb"
|
|
27
27
|
className={cn(
|
|
28
|
-
"bg-
|
|
28
|
+
"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
29
|
)}
|
|
30
30
|
/>
|
|
31
31
|
</SwitchPrimitive.Root>
|
|
@@ -39,16 +39,17 @@ function TooltipContent({
|
|
|
39
39
|
return (
|
|
40
40
|
<TooltipPrimitive.Portal>
|
|
41
41
|
<TooltipPrimitive.Content
|
|
42
|
+
data-dpe-root=""
|
|
42
43
|
data-slot="tooltip-content"
|
|
43
44
|
sideOffset={sideOffset}
|
|
44
45
|
className={cn(
|
|
45
|
-
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-dpe-md bg-
|
|
46
|
+
"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-background 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
47
|
className
|
|
47
48
|
)}
|
|
48
49
|
{...props}
|
|
49
50
|
>
|
|
50
51
|
{children}
|
|
51
|
-
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-
|
|
52
|
+
<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
53
|
</TooltipPrimitive.Content>
|
|
53
54
|
</TooltipPrimitive.Portal>
|
|
54
55
|
)
|
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
|
},
|
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.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",
|
|
@@ -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",
|