@leviosa-ai/detail-page-editor 0.7.1 → 0.7.3
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/ai-generate-panel.tsx +28 -11
- package/components/detail-page/detail-page-editor.tsx +13 -0
- package/components/detail-page/detail-page-host-context.tsx +20 -0
- package/components/detail-page/detail-page-properties-panel.tsx +79 -0
- package/components/detail-page/detail-page-sidebar-sections.tsx +3 -0
- package/components/detail-page/editor-ai-context.tsx +1 -0
- package/i18n/en.json +5 -0
- package/i18n/ko.json +5 -0
- package/lib/detail-page/authoring-image-src.ts +83 -0
- package/lib/detail-page/image-credit.ts +21 -0
- package/package.json +1 -1
- package/types/detail-page-api.ts +14 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useState, type ChangeEvent } from "react";
|
|
3
|
+
import { useCallback, useMemo, useState, type ChangeEvent } from "react";
|
|
4
4
|
import { ImagePlus, Info, Loader2, Pencil, Sparkles, X } from "lucide-react";
|
|
5
5
|
import { useTranslation } from "react-i18next";
|
|
6
6
|
|
|
@@ -8,11 +8,11 @@ import { AnnotationDialog } from "./annotation-dialog";
|
|
|
8
8
|
import { toDrawableDataUri } from "../../lib/detail-page/image-data-uri";
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
-
DEFAULT_IMAGE_TIER,
|
|
12
|
-
IMAGE_TIERS,
|
|
13
11
|
IMAGE_TIER_META,
|
|
14
12
|
isImageCreditBlocked,
|
|
15
13
|
imageCreditRequired,
|
|
14
|
+
resolveDefaultImageTier,
|
|
15
|
+
resolveImageTiers,
|
|
16
16
|
type ImageTier,
|
|
17
17
|
} from "../../lib/detail-page/image-credit";
|
|
18
18
|
import {
|
|
@@ -246,6 +246,12 @@ type AiGeneratePanelProps = {
|
|
|
246
246
|
* 계산한다. 단일 값 `creditCost`보다 우선. 데모(dev-canvas)는 대체 단가 맵을 넘긴다.
|
|
247
247
|
*/
|
|
248
248
|
costByTier?: Partial<Record<ImageTier, number>>;
|
|
249
|
+
/**
|
|
250
|
+
* 고르게 할 티어. 안 주면 셋 다(`IMAGE_TIERS`). 요금표에서 은퇴한 티어를 가진
|
|
251
|
+
* 소비자가 드롭다운에서 그 항목을 빼는 자리다 — 값이 없는 티어가 남아 있으면
|
|
252
|
+
* 누를 수는 있는데 아무도 청구를 못 한다.
|
|
253
|
+
*/
|
|
254
|
+
tiers?: readonly ImageTier[];
|
|
249
255
|
/**
|
|
250
256
|
* (레거시) 단일 티어 크레딧. `costByTier` 미주입 시 선택 티어 관계없이 이 값을 쓴다.
|
|
251
257
|
* 0/미지정이면 크레딧 UI 숨김.
|
|
@@ -286,6 +292,7 @@ export function AiGeneratePanel({
|
|
|
286
292
|
uploadFile,
|
|
287
293
|
onResult,
|
|
288
294
|
costByTier,
|
|
295
|
+
tiers,
|
|
289
296
|
creditCost = 0,
|
|
290
297
|
creditBalance = 0,
|
|
291
298
|
onBuyCredits,
|
|
@@ -296,7 +303,17 @@ export function AiGeneratePanel({
|
|
|
296
303
|
// GIF 생성이 배선된 경우에만 gif 초기값을 존중(미배선이면 토글이 없어 갇힘).
|
|
297
304
|
initialMode === "gif" && onGenerateGif ? "gif" : "image",
|
|
298
305
|
);
|
|
299
|
-
|
|
306
|
+
// 목록은 props 로 오지만 배열이라 매 렌더 새 참조다. 여기서 한 번 정규화해
|
|
307
|
+
// 아래 `useMemo`·초기값이 같은 값을 본다.
|
|
308
|
+
const tierOptions = useMemo(() => resolveImageTiers(tiers), [tiers]);
|
|
309
|
+
const [tier, setTier] = useState<ImageTier>(() =>
|
|
310
|
+
resolveDefaultImageTier(resolveImageTiers(tiers)),
|
|
311
|
+
);
|
|
312
|
+
// 목록이 좁아졌는데 고른 것이 그 밖이면(소비자가 티어를 은퇴시킨 뒤 열린 패널)
|
|
313
|
+
// 목록 안으로 되돌린다. 안 그러면 트리거가 빈 채로 뜨고 생성은 사라진 티어로 나간다.
|
|
314
|
+
const activeTier = tierOptions.includes(tier)
|
|
315
|
+
? tier
|
|
316
|
+
: resolveDefaultImageTier(tierOptions);
|
|
300
317
|
const [transparent, setTransparent] = useState(true);
|
|
301
318
|
const [refUrl, setRefUrl] = useState<string | null>(null);
|
|
302
319
|
const [refUploading, setRefUploading] = useState(false);
|
|
@@ -314,7 +331,7 @@ export function AiGeneratePanel({
|
|
|
314
331
|
);
|
|
315
332
|
|
|
316
333
|
// 활성 단가: GIF 모드는 단일 gifCreditCost, 이미지 모드는 선택 티어 단가.
|
|
317
|
-
const tierCost = costByTier?.[
|
|
334
|
+
const tierCost = costByTier?.[activeTier] ?? creditCost;
|
|
318
335
|
const activeCost = isGif ? gifCreditCost : tierCost;
|
|
319
336
|
|
|
320
337
|
// 잔액이 비용의 1.5배 미만이면 생성 차단(비용 0=미구성이면 게이트 비활성).
|
|
@@ -380,7 +397,7 @@ export function AiGeneratePanel({
|
|
|
380
397
|
prompt: finalPrompt,
|
|
381
398
|
referenceImages,
|
|
382
399
|
mode: refUrl ? "new_reference" : "based_on_existing",
|
|
383
|
-
tier,
|
|
400
|
+
tier: activeTier,
|
|
384
401
|
brandId,
|
|
385
402
|
annotatedImage,
|
|
386
403
|
});
|
|
@@ -408,7 +425,7 @@ export function AiGeneratePanel({
|
|
|
408
425
|
} finally {
|
|
409
426
|
setLoading(false);
|
|
410
427
|
}
|
|
411
|
-
}, [prompt, loading, blocked, isGif, gifMissingRef, transparent, onGenerate, onGenerateGif, refUrl, store, onResult,
|
|
428
|
+
}, [prompt, loading, blocked, isGif, gifMissingRef, transparent, onGenerate, onGenerateGif, refUrl, store, onResult, activeTier, t]);
|
|
412
429
|
|
|
413
430
|
const handleGenerate = useCallback(() => {
|
|
414
431
|
void runGenerate();
|
|
@@ -529,7 +546,7 @@ export function AiGeneratePanel({
|
|
|
529
546
|
<TooltipContent side="left" className="max-w-[240px] text-left">
|
|
530
547
|
<p className="mb-1 font-dpe-semibold">{t("detailPage.aiGenerate.modelTooltipTitle")}</p>
|
|
531
548
|
<ul className="space-y-1">
|
|
532
|
-
{
|
|
549
|
+
{tierOptions.map((tk) => (
|
|
533
550
|
<li key={tk}>
|
|
534
551
|
<span className="font-dpe-medium">{IMAGE_TIER_META[tk].label}</span>
|
|
535
552
|
<span className="opacity-80"> · {IMAGE_TIER_META[tk].quality}</span>
|
|
@@ -539,12 +556,12 @@ export function AiGeneratePanel({
|
|
|
539
556
|
</TooltipContent>
|
|
540
557
|
</Tooltip>
|
|
541
558
|
</div>
|
|
542
|
-
<Select value={
|
|
559
|
+
<Select value={activeTier} onValueChange={(v) => setTier(v as ImageTier)}>
|
|
543
560
|
<SelectTrigger className="h-10 w-full text-sm">
|
|
544
561
|
<SelectValue />
|
|
545
562
|
</SelectTrigger>
|
|
546
563
|
<SelectContent>
|
|
547
|
-
{
|
|
564
|
+
{tierOptions.map((tk) => {
|
|
548
565
|
const meta = IMAGE_TIER_META[tk];
|
|
549
566
|
const cost = costByTier?.[tk];
|
|
550
567
|
return (
|
|
@@ -573,7 +590,7 @@ export function AiGeneratePanel({
|
|
|
573
590
|
</SelectContent>
|
|
574
591
|
</Select>
|
|
575
592
|
<p className="mt-1.5 text-[11px] leading-4 text-dpe-ink-500">
|
|
576
|
-
{IMAGE_TIER_META[
|
|
593
|
+
{IMAGE_TIER_META[activeTier].description}
|
|
577
594
|
</p>
|
|
578
595
|
</div>
|
|
579
596
|
|
|
@@ -92,6 +92,14 @@ export type DetailPageEditorProps = {
|
|
|
92
92
|
* 쓴다. 인증된 호스트가 공유 훅으로 feature_costs에서 계산해 주입한다.
|
|
93
93
|
*/
|
|
94
94
|
imageCostByTier?: Partial<Record<ImageTier, number>>;
|
|
95
|
+
/**
|
|
96
|
+
* 고르게 할 이미지 티어. 안 주면 셋 다(basic/pro/max).
|
|
97
|
+
*
|
|
98
|
+
* 요금표에서 티어를 은퇴시킨 소비자가 쓰는 자리다 — 값이 없는 티어를 드롭다운에
|
|
99
|
+
* 남겨 두면 누를 수는 있는데 아무도 청구를 못 하고, 그 상태는 화면 어디에도 안
|
|
100
|
+
* 보인다. 에이전시는 `["pro", "max"]` 를 준다.
|
|
101
|
+
*/
|
|
102
|
+
imageTiers?: readonly ImageTier[];
|
|
95
103
|
/**
|
|
96
104
|
* 생성 인스턴스 ID. 주어지면 텍스트 요소 선택 시 우측에 "프롬프트로 편집"이
|
|
97
105
|
* 노출된다(소싱 서버 카피 엔진 호출). dev 하네스는 scratch ID를 주입한다.
|
|
@@ -435,6 +443,7 @@ export function DetailPageEditor({
|
|
|
435
443
|
imageCreditCost = 0,
|
|
436
444
|
imageCreditBalance = 0,
|
|
437
445
|
imageCostByTier,
|
|
446
|
+
imageTiers,
|
|
438
447
|
generatedId,
|
|
439
448
|
structurePanel,
|
|
440
449
|
productName,
|
|
@@ -662,6 +671,7 @@ export function DetailPageEditor({
|
|
|
662
671
|
imageCreditCost,
|
|
663
672
|
imageCreditBalance,
|
|
664
673
|
imageCostByTier,
|
|
674
|
+
imageTiers,
|
|
665
675
|
onBuyImageCredits: () => setPricingOpen(true),
|
|
666
676
|
structurePanel,
|
|
667
677
|
generatedId,
|
|
@@ -675,6 +685,7 @@ export function DetailPageEditor({
|
|
|
675
685
|
imageCreditCost,
|
|
676
686
|
imageCreditBalance,
|
|
677
687
|
imageCostByTier,
|
|
688
|
+
imageTiers,
|
|
678
689
|
structurePanel,
|
|
679
690
|
generatedId,
|
|
680
691
|
],
|
|
@@ -721,6 +732,7 @@ export function DetailPageEditor({
|
|
|
721
732
|
imageCreditCost,
|
|
722
733
|
imageCreditBalance,
|
|
723
734
|
imageCostByTier,
|
|
735
|
+
imageTiers,
|
|
724
736
|
onGenerateGif,
|
|
725
737
|
gifCreditCost,
|
|
726
738
|
onRemoveBackground,
|
|
@@ -733,6 +745,7 @@ export function DetailPageEditor({
|
|
|
733
745
|
imageCreditCost,
|
|
734
746
|
imageCreditBalance,
|
|
735
747
|
imageCostByTier,
|
|
748
|
+
imageTiers,
|
|
736
749
|
onGenerateGif,
|
|
737
750
|
gifCreditCost,
|
|
738
751
|
onRemoveBackground,
|
|
@@ -40,6 +40,7 @@ import type {
|
|
|
40
40
|
DetailPageGroupEditItem,
|
|
41
41
|
DetailPageGroupPromptEditResult,
|
|
42
42
|
DetailPageImageEditResult,
|
|
43
|
+
DetailPagePromoteImageResult,
|
|
43
44
|
DetailPageInsufficientCreditsDetail,
|
|
44
45
|
DetailPagePersonalShapeSaveResult,
|
|
45
46
|
DetailPagePromptEditResult,
|
|
@@ -117,6 +118,25 @@ export interface DetailPageHostApi {
|
|
|
117
118
|
},
|
|
118
119
|
signal?: AbortSignal,
|
|
119
120
|
) => Promise<DetailPageImageEditResult>;
|
|
121
|
+
/**
|
|
122
|
+
* 저작이 만든 사진 한 장을 브랜드 자산으로 승격한다.
|
|
123
|
+
*
|
|
124
|
+
* 사진은 문서에 이미 박혀 있는 `job_id`·`name`·`sig` 로 가리킨다 — 서버가 그 서명을
|
|
125
|
+
* 먼저 검증하므로, 편집기가 임의의 주소를 대신 받아 오게 만들 수 없다.
|
|
126
|
+
*
|
|
127
|
+
* 선택 사항이다. 호스트가 안 주면 "브랜드 이미지로 저장" 버튼이 뜨지 않는다 — 이
|
|
128
|
+
* 편집기를 쓰는 앱이 둘이라, 필수로 두면 한쪽이 늦는 동안 다른 쪽까지 못 올린다.
|
|
129
|
+
*/
|
|
130
|
+
promoteAuthoringImageToBrand?: (
|
|
131
|
+
payload: {
|
|
132
|
+
job_id: string;
|
|
133
|
+
name: string;
|
|
134
|
+
sig: string;
|
|
135
|
+
brand_id: string;
|
|
136
|
+
display_name?: string;
|
|
137
|
+
},
|
|
138
|
+
signal?: AbortSignal,
|
|
139
|
+
) => Promise<DetailPagePromoteImageResult>;
|
|
120
140
|
svgPromptEditDetailPage: (
|
|
121
141
|
generatedId: string,
|
|
122
142
|
payload: { slot_role?: string; current_svg: string; instruction: string },
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
Ungroup,
|
|
27
27
|
Type as TypeIcon,
|
|
28
28
|
Image as ImageIcon,
|
|
29
|
+
ImagePlus,
|
|
29
30
|
Film,
|
|
30
31
|
Eraser,
|
|
31
32
|
Square,
|
|
@@ -70,6 +71,7 @@ import {
|
|
|
70
71
|
import { readColorReplace } from "@leviosa-ai/canvas/render/svg-source";
|
|
71
72
|
import { selectedElementsDeep } from "./detail-page-selection";
|
|
72
73
|
import { useEditorAi } from "./editor-ai-context";
|
|
74
|
+
import { parseAuthoringImageSrc } from "../../lib/detail-page/authoring-image-src";
|
|
73
75
|
import { PromptEditPanel } from "./prompt-edit-panel";
|
|
74
76
|
import { SvgPromptEditPanel } from "./svg-prompt-edit-panel";
|
|
75
77
|
import { GroupPromptEditPanel } from "./group-prompt-edit-panel";
|
|
@@ -1886,6 +1888,78 @@ async function saveShapeToMyShapes(
|
|
|
1886
1888
|
}
|
|
1887
1889
|
}
|
|
1888
1890
|
|
|
1891
|
+
/**
|
|
1892
|
+
* 저작 사진을 브랜드 라이브러리에 남기는 버튼.
|
|
1893
|
+
*
|
|
1894
|
+
* 저작 사진은 잡 아래 산다. 고른 잡은 이제 만료하지 않으니 사진이 죽지는 않지만, 그
|
|
1895
|
+
* 사진은 여전히 **그 잡의 것**이라 다음 상품을 만들 때 라이브러리에서 고를 수 없다.
|
|
1896
|
+
* 승격은 한 장을 브랜드 자산으로 복사해 그 벽을 없앤다.
|
|
1897
|
+
*
|
|
1898
|
+
* 전부 자동으로 복사하지 않는 이유는 저작 한 번에 십수 장이 들어와 라이브러리가
|
|
1899
|
+
* 폐기물로 차기 때문이다 — **셀러가 누르는 행위 자체가 신호다.**
|
|
1900
|
+
*
|
|
1901
|
+
* 사진의 `src` 가 저작 주소로 파싱될 때만 뜬다. 셀러가 직접 올린 것, 브랜드에서 가져온
|
|
1902
|
+
* 것, 이미 브랜드에 들어간 AI 이미지는 승격할 이유가 없다.
|
|
1903
|
+
*/
|
|
1904
|
+
const SaveImageToBrandButton = observer(function SaveImageToBrandButton({
|
|
1905
|
+
src,
|
|
1906
|
+
label,
|
|
1907
|
+
}: {
|
|
1908
|
+
src: string;
|
|
1909
|
+
label: string;
|
|
1910
|
+
}) {
|
|
1911
|
+
const { t } = useTranslation("branding");
|
|
1912
|
+
const host = useDetailPageHost();
|
|
1913
|
+
const { brand } = host;
|
|
1914
|
+
const [saving, setSaving] = useState(false);
|
|
1915
|
+
|
|
1916
|
+
const ref = parseAuthoringImageSrc(src);
|
|
1917
|
+
const brandId = brand.getStoredActiveBrandId() ?? "";
|
|
1918
|
+
const promote = host.api.promoteAuthoringImageToBrand;
|
|
1919
|
+
// 브랜드가 안 골라져 있으면 넣을 곳이 없다. 버튼을 띄워 놓고 누른 뒤에 알려 주면
|
|
1920
|
+
// 셀러는 저장이 실패했다고 읽는다. 호스트가 승격을 안 붙였을 때도 마찬가지다.
|
|
1921
|
+
if (!ref || !brandId || !promote) return null;
|
|
1922
|
+
|
|
1923
|
+
const save = async () => {
|
|
1924
|
+
if (saving) return;
|
|
1925
|
+
setSaving(true);
|
|
1926
|
+
try {
|
|
1927
|
+
const res = await promote({
|
|
1928
|
+
job_id: ref.jobId,
|
|
1929
|
+
name: ref.name,
|
|
1930
|
+
sig: ref.sig,
|
|
1931
|
+
brand_id: brandId,
|
|
1932
|
+
display_name: label,
|
|
1933
|
+
});
|
|
1934
|
+
host.toast.success(
|
|
1935
|
+
res.reused
|
|
1936
|
+
? t("detailPage.properties.saveToBrandDuplicate")
|
|
1937
|
+
: t("detailPage.properties.saveToBrandDone"),
|
|
1938
|
+
);
|
|
1939
|
+
} catch {
|
|
1940
|
+
host.toast.error(t("detailPage.properties.saveToBrandFailed"));
|
|
1941
|
+
} finally {
|
|
1942
|
+
setSaving(false);
|
|
1943
|
+
}
|
|
1944
|
+
};
|
|
1945
|
+
|
|
1946
|
+
return (
|
|
1947
|
+
<div className="border-t border-dpe-ink-200 px-4 py-2">
|
|
1948
|
+
<button
|
|
1949
|
+
type="button"
|
|
1950
|
+
disabled={saving}
|
|
1951
|
+
onClick={() => void save()}
|
|
1952
|
+
className="flex w-full items-center justify-center gap-1.5 rounded-dpe-lg border border-dpe-ink-200 py-2 text-xs font-dpe-medium text-dpe-ink-600 hover:border-dpe-ink-400 hover:bg-dpe-ink-50 disabled:opacity-50"
|
|
1953
|
+
>
|
|
1954
|
+
<ImagePlus size={13} />
|
|
1955
|
+
{saving
|
|
1956
|
+
? t("detailPage.properties.saveToBrandSaving")
|
|
1957
|
+
: t("detailPage.properties.saveToBrand")}
|
|
1958
|
+
</button>
|
|
1959
|
+
</div>
|
|
1960
|
+
);
|
|
1961
|
+
});
|
|
1962
|
+
|
|
1889
1963
|
// svg 도형(벡터 장식)용 인스펙터: 불투명도 + "내 도형에 저장" + 프롬프트 편집(생성
|
|
1890
1964
|
// ID·디코드 가능한 마크업이 있을 때만) + 삭제. figure 등 마크업이 없는 도형은
|
|
1891
1965
|
// 저장/프롬프트 편집을 숨긴다.
|
|
@@ -2296,10 +2370,15 @@ export const ElementAiEditPanel = observer(function ElementAiEditPanel({
|
|
|
2296
2370
|
initialMode={isGif ? "gif" : "image"}
|
|
2297
2371
|
onResult={(src) => single.set({ src })}
|
|
2298
2372
|
costByTier={ai.imageCostByTier}
|
|
2373
|
+
tiers={ai.imageTiers}
|
|
2299
2374
|
creditCost={ai.imageCreditCost}
|
|
2300
2375
|
creditBalance={ai.imageCreditBalance}
|
|
2301
2376
|
onBuyCredits={ai.onBuyCredits}
|
|
2302
2377
|
/>
|
|
2378
|
+
<SaveImageToBrandButton
|
|
2379
|
+
src={str(single.src)}
|
|
2380
|
+
label={str(singleCustom.leviosaSlot ?? "")}
|
|
2381
|
+
/>
|
|
2303
2382
|
</section>
|
|
2304
2383
|
);
|
|
2305
2384
|
}
|
|
@@ -138,6 +138,7 @@ export type BuildSectionsOptions = {
|
|
|
138
138
|
imageCreditBalance?: number;
|
|
139
139
|
/** 티어별(basic/pro/max) 크레딧 단가. 모델 드롭다운의 각 항목 비용에 쓴다. */
|
|
140
140
|
imageCostByTier?: Partial<Record<ImageTier, number>>;
|
|
141
|
+
imageTiers?: readonly ImageTier[];
|
|
141
142
|
/** 크레딧 부족 시 "크레딧 추가하기" 목적지(레비오사 pricing). */
|
|
142
143
|
onBuyImageCredits?: () => void;
|
|
143
144
|
/** AI 생성 결과를 현재 브랜드 중앙 에셋에 미러링. */
|
|
@@ -167,6 +168,7 @@ export function buildDetailPageSections({
|
|
|
167
168
|
imageCreditCost,
|
|
168
169
|
imageCreditBalance,
|
|
169
170
|
imageCostByTier,
|
|
171
|
+
imageTiers,
|
|
170
172
|
onBuyImageCredits,
|
|
171
173
|
structurePanel,
|
|
172
174
|
generatedId,
|
|
@@ -225,6 +227,7 @@ export function buildDetailPageSections({
|
|
|
225
227
|
gifCreditCost={gifCreditCost}
|
|
226
228
|
uploadFile={uploadFile}
|
|
227
229
|
costByTier={imageCostByTier}
|
|
230
|
+
tiers={imageTiers}
|
|
228
231
|
creditCost={imageCreditCost}
|
|
229
232
|
creditBalance={imageCreditBalance}
|
|
230
233
|
onBuyCredits={onBuyImageCredits}
|
|
@@ -29,6 +29,7 @@ export type EditorAiValue = {
|
|
|
29
29
|
imageCreditCost?: number;
|
|
30
30
|
imageCreditBalance?: number;
|
|
31
31
|
imageCostByTier?: Partial<Record<ImageTier, number>>;
|
|
32
|
+
imageTiers?: readonly ImageTier[];
|
|
32
33
|
onGenerateGif?: GenerateGifFn;
|
|
33
34
|
gifCreditCost?: number;
|
|
34
35
|
onRemoveBackground?: RemoveBackgroundFn;
|
package/i18n/en.json
CHANGED
|
@@ -104,6 +104,11 @@
|
|
|
104
104
|
"shapeSaved": "Saved to brand shapes.",
|
|
105
105
|
"shapeColors": "Colors",
|
|
106
106
|
"shapeColorsReset": "Reset to original colors",
|
|
107
|
+
"saveToBrand": "Save to brand images",
|
|
108
|
+
"saveToBrandSaving": "Saving…",
|
|
109
|
+
"saveToBrandDone": "Saved to brand images.",
|
|
110
|
+
"saveToBrandDuplicate": "Already in brand images.",
|
|
111
|
+
"saveToBrandFailed": "Could not save to brand images.",
|
|
107
112
|
"saveToMyShapes": "Save to brand shapes",
|
|
108
113
|
"aiShapeEdit": "AI shape editing",
|
|
109
114
|
"pageHeight": "Section height",
|
package/i18n/ko.json
CHANGED
|
@@ -104,6 +104,11 @@
|
|
|
104
104
|
"shapeSaved": "브랜드 도형에 저장했어요.",
|
|
105
105
|
"shapeColors": "색",
|
|
106
106
|
"shapeColorsReset": "원래 색으로 되돌리기",
|
|
107
|
+
"saveToBrand": "브랜드 이미지로 저장",
|
|
108
|
+
"saveToBrandSaving": "저장하는 중…",
|
|
109
|
+
"saveToBrandDone": "브랜드 이미지에 저장했어요.",
|
|
110
|
+
"saveToBrandDuplicate": "이미 브랜드 이미지에 있어요.",
|
|
111
|
+
"saveToBrandFailed": "브랜드 이미지에 저장하지 못했어요.",
|
|
107
112
|
"saveToMyShapes": "브랜드 도형에 저장",
|
|
108
113
|
"aiShapeEdit": "AI 도형 편집",
|
|
109
114
|
"pageHeight": "섹션 높이",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 저작이 만든 사진인지 **주소만 보고** 알아낸다.
|
|
3
|
+
*
|
|
4
|
+
* 브랜드 저작이 만든 사진은 문서에 잡 주소로 박힌다:
|
|
5
|
+
*
|
|
6
|
+
* ```
|
|
7
|
+
* https://<host>/api/v2/detail-pages/brand-authoring/{job_id}/images/{name}?sig=<hmac>
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* 이 사진을 브랜드 자산으로 승격하려면 서버에 그 셋(`job_id`·`name`·`sig`)을 그대로
|
|
11
|
+
* 넘겨야 한다. 서버는 서명을 먼저 검증하고 나서야 바이트를 읽는다 — 그렇게 하지 않으면
|
|
12
|
+
* 로그인만 하면 임의의 주소를 대신 받아 오는 통로가 된다.
|
|
13
|
+
*
|
|
14
|
+
* 편집기가 들고 있는 것은 `el.src` 뿐이므로, 승격 버튼을 띄울지 말지도 이 파싱 하나로
|
|
15
|
+
* 정해진다. **파싱되지 않으면 저작 사진이 아니다** — 셀러가 직접 올린 것, 브랜드
|
|
16
|
+
* 라이브러리에서 가져온 것, AI 가 만들어 이미 브랜드에 들어간 것은 모두 승격할 이유가
|
|
17
|
+
* 없고, 실제로 이 모양이 아니다.
|
|
18
|
+
*
|
|
19
|
+
* 상대 주소·절대 주소·프록시 경유를 모두 받는다. 어느 쪽이든 경로와 질의만 보면 된다.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface AuthoringImageRef {
|
|
23
|
+
/** 이 사진을 만든 저작 잡. */
|
|
24
|
+
jobId: string;
|
|
25
|
+
/** 명세가 정한 사진 이름. */
|
|
26
|
+
name: string;
|
|
27
|
+
/** 주소에 이미 붙어 있는 HMAC 서명. 서버가 권한 증거로 쓴다. */
|
|
28
|
+
sig: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 라우터 mount prefix + 라우트 경로. 소싱 서버의 `BRAND_LANE_IMAGE_PATH` 와 같아야 한다. */
|
|
32
|
+
const LANE_IMAGE_PATH = "/api/v2/detail-pages/brand-authoring/";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 저작 사진 주소를 (잡, 이름, 서명)으로 쪼갠다. 저작 사진이 아니면 `null`.
|
|
36
|
+
*
|
|
37
|
+
* 셋 중 하나라도 비면 `null` 이다. 반쪽짜리를 넘기면 서버는 어차피 404 로 돌려보내는데,
|
|
38
|
+
* 버튼은 이미 눌린 뒤라 셀러에게는 "저장이 실패했다"로 보인다.
|
|
39
|
+
*/
|
|
40
|
+
export function parseAuthoringImageSrc(src: string): AuthoringImageRef | null {
|
|
41
|
+
const raw = String(src ?? "").trim();
|
|
42
|
+
if (!raw || raw.startsWith("data:") || raw.startsWith("blob:")) return null;
|
|
43
|
+
|
|
44
|
+
// 절대 주소로 와도 경로만 보면 된다. base 는 상대 주소를 파싱하기 위한 것일 뿐이라
|
|
45
|
+
// 값 자체는 쓰이지 않는다.
|
|
46
|
+
let url: URL;
|
|
47
|
+
try {
|
|
48
|
+
url = new URL(raw, "https://placeholder.invalid");
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const path = url.pathname;
|
|
54
|
+
if (!path.startsWith(LANE_IMAGE_PATH)) return null;
|
|
55
|
+
|
|
56
|
+
const rest = path.slice(LANE_IMAGE_PATH.length).replace(/\/+$/, "");
|
|
57
|
+
const parts = rest.split("/");
|
|
58
|
+
// {job_id}/images/{name} — 정확히 셋이어야 한다. 폴링(`{job_id}`)이나
|
|
59
|
+
// 고르기(`{job_id}/select`)가 같이 걸리면 안 된다.
|
|
60
|
+
if (parts.length !== 3 || parts[1] !== "images") return null;
|
|
61
|
+
|
|
62
|
+
const jobId = safeDecode(parts[0]);
|
|
63
|
+
const name = safeDecode(parts[2]);
|
|
64
|
+
const sig = url.searchParams.get("sig") ?? "";
|
|
65
|
+
if (!jobId || !name || !sig) return null;
|
|
66
|
+
|
|
67
|
+
return { jobId, name, sig };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 저작 사진인지만 묻는다. 버튼을 띄울지 정하는 자리에서 쓴다. */
|
|
71
|
+
export function isAuthoringImageSrc(src: string): boolean {
|
|
72
|
+
return parseAuthoringImageSrc(src) !== null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function safeDecode(value: string): string {
|
|
76
|
+
try {
|
|
77
|
+
return decodeURIComponent(value);
|
|
78
|
+
} catch {
|
|
79
|
+
// 인코딩이 깨진 이름은 디코드하면 던진다. 그때는 원문 그대로 넘긴다 — 서명이
|
|
80
|
+
// 원문에 대해 만들어졌으므로 서버가 판단하게 두는 편이 맞다.
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -13,6 +13,27 @@ export type ImageTier = "basic" | "pro" | "max";
|
|
|
13
13
|
/** 선택 UI에 노출할 티어 순서(저→고 품질). */
|
|
14
14
|
export const IMAGE_TIERS: ImageTier[] = ["basic", "pro", "max"];
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* 이 편집기가 실제로 고르게 할 티어.
|
|
18
|
+
*
|
|
19
|
+
* 소비자마다 파는 것이 다르다. 소싱은 셋을 다 팔지만, 에이전시 요금표는 basic 을
|
|
20
|
+
* 은퇴시키고 pro 를 기본으로 뒀다 — 값이 없는 티어를 드롭다운에 남겨 두면 사용자는
|
|
21
|
+
* 고를 수 있는데 아무도 청구를 못 하는 자리가 생긴다.
|
|
22
|
+
*
|
|
23
|
+
* 안 주면 지금까지대로 셋 다. 준 값이 비었거나 아는 티어가 하나도 없으면 그것도
|
|
24
|
+
* 셋 다로 되돌린다 — 빈 드롭다운은 "AI 이미지를 못 만드는 편집기"이고, 그건 소비자가
|
|
25
|
+
* 배열 하나를 잘못 넘겨서 벌어질 만한 일이 아니다.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveImageTiers(allowed?: readonly ImageTier[]): ImageTier[] {
|
|
28
|
+
const picked = IMAGE_TIERS.filter((tier) => allowed?.includes(tier));
|
|
29
|
+
return picked.length > 0 ? picked : IMAGE_TIERS;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 목록 안의 기본 티어. 기본값이 빠진 목록이면 그 목록의 첫 항목. */
|
|
33
|
+
export function resolveDefaultImageTier(tiers: readonly ImageTier[]): ImageTier {
|
|
34
|
+
return tiers.includes(DEFAULT_IMAGE_TIER) ? DEFAULT_IMAGE_TIER : tiers[0];
|
|
35
|
+
}
|
|
36
|
+
|
|
16
37
|
export const IMAGE_FEATURE_KEYS: Record<ImageTier, string> = {
|
|
17
38
|
basic: "detail_page_image_basic",
|
|
18
39
|
pro: "detail_page_image_pro",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leviosa-ai/detail-page-editor",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
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",
|
package/types/detail-page-api.ts
CHANGED
|
@@ -89,6 +89,20 @@ export interface DetailPageGroupPromptEditResult {
|
|
|
89
89
|
svg_limit: number;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/** 저작 사진을 브랜드 자산으로 승격한 결과. */
|
|
93
|
+
export interface DetailPagePromoteImageResult {
|
|
94
|
+
asset_id: string;
|
|
95
|
+
/**
|
|
96
|
+
* 15분짜리 presigned. **문서에 박으면 안 된다** — 그 시간 뒤에 403 이 된다.
|
|
97
|
+
* 문서에 남길 주소는 `stable_path` 다.
|
|
98
|
+
*/
|
|
99
|
+
url?: string;
|
|
100
|
+
/** 만료가 없는 서명 경로. 브랜드 라이브러리가 이 주소로 이 사진을 연다. */
|
|
101
|
+
stable_path?: string;
|
|
102
|
+
/** 같은 바이트가 이미 있어 새로 넣지 않았다는 뜻. */
|
|
103
|
+
reused?: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
92
106
|
export interface DetailPageImageEditResult {
|
|
93
107
|
success: boolean;
|
|
94
108
|
message?: string;
|