@leviosa-ai/detail-page-editor 0.6.4 → 0.7.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/detail-page/detail-page-editor.tsx +9 -0
- package/components/detail-page/detail-page-font-picker.tsx +32 -8
- package/components/detail-page/detail-page-properties-panel.tsx +3 -2
- package/i18n/en.json +86 -7
- package/i18n/index.ts +38 -1
- package/i18n/ko.json +86 -7
- package/index.ts +16 -0
- package/lib/cardnews/font-loader.ts +14 -8
- package/lib/detail-page/design-reference.ts +5 -0
- package/lib/detail-page/icons.ts +8 -3
- package/lib/detail-page/runtime-config.ts +166 -0
- package/lib/detail-page/stock-photos.ts +9 -4
- package/lib/detail-page-canvas/editor-fonts.ts +3 -2
- package/lib/detail-page-canvas/gif-web-fonts.ts +8 -6
- package/package.json +6 -2
- package/server/icons.ts +495 -0
- package/server/route-kit.ts +104 -0
- package/server/stock-photos.ts +163 -0
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
applyTextLineFit,
|
|
21
21
|
clearPlaceholderImageSrc,
|
|
22
22
|
} from "../../lib/detail-page-canvas/custom-props-adapter";
|
|
23
|
+
import { TooltipProvider } from "../ui/tooltip";
|
|
23
24
|
import { CanvasStoreContext } from "./canvas-observer";
|
|
24
25
|
import { LeviosaCanvasWorkspace } from "./leviosa-canvas-workspace";
|
|
25
26
|
import { DetailPagePagesTimeline } from "./detail-page-pages-timeline";
|
|
@@ -1018,6 +1019,13 @@ export function DetailPageEditor({
|
|
|
1018
1019
|
<CanvasStoreContext.Provider value={store}>
|
|
1019
1020
|
{/* 캔버스 위 띠가 쓰는 값들. props로 내리면 작업 영역이 통째로 다시 만들어진다. */}
|
|
1020
1021
|
<EditorAiProvider value={aiValue}>
|
|
1022
|
+
{/* 편집기가 자기 툴팁 프로바이더를 깐다.
|
|
1023
|
+
예전에는 안 깔았다 — 첫 소비자(leviosa-frontend)가 앱 레이아웃에 전역으로 하나
|
|
1024
|
+
갖고 있어서 우연히 서 있었을 뿐이다. 그것이 없는 소비자에서는 AI 생성 패널이
|
|
1025
|
+
열리는 순간 `Tooltip must be used within TooltipProvider` 로 화면이 통째로
|
|
1026
|
+
죽었다. Radix 의 프로바이더는 중첩이 안전하므로(안쪽이 이긴다), 이미 깔아 둔
|
|
1027
|
+
앱에도 해가 없다. */}
|
|
1028
|
+
<TooltipProvider>
|
|
1021
1029
|
<div
|
|
1022
1030
|
data-dpe-root=""
|
|
1023
1031
|
className="flex h-screen min-h-[640px] flex-col bg-dpe-ink-100"
|
|
@@ -1046,6 +1054,7 @@ export function DetailPageEditor({
|
|
|
1046
1054
|
<slots.PricingModal open={pricingOpen} onOpenChange={setPricingOpen} />
|
|
1047
1055
|
) : null}
|
|
1048
1056
|
</div>
|
|
1057
|
+
</TooltipProvider>
|
|
1049
1058
|
</EditorAiProvider>
|
|
1050
1059
|
</CanvasStoreContext.Provider>
|
|
1051
1060
|
);
|
|
@@ -37,6 +37,10 @@ export function DetailPageFontPicker({
|
|
|
37
37
|
const [loading, setLoading] = useState<string | null>(null);
|
|
38
38
|
const [error, setError] = useState(false);
|
|
39
39
|
const [tags, setTags] = useState<FontTag[]>([]);
|
|
40
|
+
/** 404 로 떨어진 미리보기. 한 번 떨어지면 다시 안 부른다. */
|
|
41
|
+
const [previewsMissing, setPreviewsMissing] = useState<ReadonlySet<string>>(
|
|
42
|
+
() => new Set(),
|
|
43
|
+
);
|
|
40
44
|
const selected = getEditorFont(value);
|
|
41
45
|
const catalogFonts = filterFontsByTags(EDITOR_CATALOG_FONTS, tags);
|
|
42
46
|
const bundleFonts = filterFontsByTags(EDITOR_BUNDLE_FONTS, tags);
|
|
@@ -68,6 +72,14 @@ export function DetailPageFontPicker({
|
|
|
68
72
|
}
|
|
69
73
|
};
|
|
70
74
|
|
|
75
|
+
/**
|
|
76
|
+
* 미리보기 WebP 는 소비자 앱의 정적 자산이다(`assets.detailFontPreviews`).
|
|
77
|
+
*
|
|
78
|
+
* 그걸 굽는 스크립트를 아직 안 돌린 소비자에서는 이 그림이 통째로 404 다. 예전에는
|
|
79
|
+
* 그 자리에 깨진 이미지 아이콘과 alt 글자만 남아, 목록이 폰트 이름표만 늘어선 줄이
|
|
80
|
+
* 됐다 — 무엇을 고르는지 볼 수가 없다. 떨어지면 **이름을 그 폰트로** 그린다.
|
|
81
|
+
* 완전한 미리보기는 아니지만(가변 자소 몇 자뿐), 고를 수는 있다.
|
|
82
|
+
*/
|
|
71
83
|
const renderFont = (font: EditorFont) => (
|
|
72
84
|
<CommandItem
|
|
73
85
|
key={`${font.source}:${font.id}`}
|
|
@@ -77,14 +89,26 @@ export function DetailPageFontPicker({
|
|
|
77
89
|
className="group min-h-[56px] gap-2 px-2 py-1"
|
|
78
90
|
>
|
|
79
91
|
<span className="flex h-11 min-w-0 flex-1 items-center overflow-hidden">
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
{previewsMissing.has(font.previewSrc) ? (
|
|
93
|
+
<span
|
|
94
|
+
className="truncate text-lg leading-none"
|
|
95
|
+
style={{ fontFamily: `"${font.family}", sans-serif` }}
|
|
96
|
+
>
|
|
97
|
+
{font.label}
|
|
98
|
+
</span>
|
|
99
|
+
) : (
|
|
100
|
+
<Image
|
|
101
|
+
src={font.previewSrc}
|
|
102
|
+
alt={font.label}
|
|
103
|
+
width={560}
|
|
104
|
+
height={88}
|
|
105
|
+
className="h-auto w-full object-contain object-left"
|
|
106
|
+
unoptimized
|
|
107
|
+
onError={() =>
|
|
108
|
+
setPreviewsMissing((seen) => new Set(seen).add(font.previewSrc))
|
|
109
|
+
}
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
88
112
|
</span>
|
|
89
113
|
{font.latinOnly ? (
|
|
90
114
|
<span className="shrink-0 rounded border border-dpe-ink-200 px-1 py-0.5 text-[10px] leading-none text-dpe-ink-500">
|
|
@@ -91,6 +91,7 @@ import {
|
|
|
91
91
|
type RemoveBackgroundFn,
|
|
92
92
|
} from "./ai-generate-panel";
|
|
93
93
|
import { toHexColor } from "../../lib/detail-page/css-color";
|
|
94
|
+
import { editorAssetBase } from "../../lib/detail-page/runtime-config";
|
|
94
95
|
import { setZ as setElementZ, zOrderOf } from "../../lib/detail-page/z-order";
|
|
95
96
|
import {
|
|
96
97
|
canDistribute,
|
|
@@ -959,7 +960,7 @@ function textGifEffects(t: Translate): GifEffectOption[] {
|
|
|
959
960
|
id,
|
|
960
961
|
label: t(`detailPage.gifEffects.text.${id}.label`),
|
|
961
962
|
hint: t(`detailPage.gifEffects.text.${id}.hint`),
|
|
962
|
-
previewSrc:
|
|
963
|
+
previewSrc: `${editorAssetBase("gifEffectPreviews")}/text-${id}.gif`,
|
|
963
964
|
}));
|
|
964
965
|
}
|
|
965
966
|
|
|
@@ -1033,7 +1034,7 @@ function imageGifEffects(t: Translate, shape: boolean): GifEffectOption[] {
|
|
|
1033
1034
|
label: t(`detailPage.gifEffects.image.${effect.id}.label`),
|
|
1034
1035
|
hint: t(`detailPage.gifEffects.image.${effect.id}.hint`),
|
|
1035
1036
|
group: shape ? undefined : t(IMAGE_GIF_GROUP_KEY[effect.group]),
|
|
1036
|
-
previewSrc:
|
|
1037
|
+
previewSrc: `${editorAssetBase("gifEffectPreviews")}/image-${effect.id}.gif`,
|
|
1037
1038
|
}));
|
|
1038
1039
|
}
|
|
1039
1040
|
|
package/i18n/en.json
CHANGED
|
@@ -143,7 +143,29 @@
|
|
|
143
143
|
"weight600": "Semi bold",
|
|
144
144
|
"weight700": "Bold",
|
|
145
145
|
"weight800": "Extra bold",
|
|
146
|
-
"weight900": "Black"
|
|
146
|
+
"weight900": "Black",
|
|
147
|
+
"alignInGroup": "Align within group",
|
|
148
|
+
"alignInSection": "Align within section",
|
|
149
|
+
"cellGridCols": "Columns",
|
|
150
|
+
"cellGridGif": "Cell fill GIF",
|
|
151
|
+
"cellGridGifHint": "Filled cells light up in order, hold 2s, then repeat. Add axis and value labels on the canvas.",
|
|
152
|
+
"cellGridGifMake": "Make chart GIF",
|
|
153
|
+
"cellGridInvalid": "Each row needs at least one cell and cannot exceed the column count.",
|
|
154
|
+
"cellGridRows": "Filled cells per row",
|
|
155
|
+
"cellGridShape": "Shape",
|
|
156
|
+
"countUpGif": "Number to count-up",
|
|
157
|
+
"countUpGifHint": "Counts up from 0 to {{target}}, holds 2s, then repeats. It lands right here.",
|
|
158
|
+
"countUpGifMake": "Make count-up GIF",
|
|
159
|
+
"gifShapeUnreadable": "Could not read the shape.",
|
|
160
|
+
"gifStageDetecting": "Finding the subject…",
|
|
161
|
+
"gifStageEncoding": "Encoding the GIF…",
|
|
162
|
+
"gifStagePreparing": "Preparing…",
|
|
163
|
+
"gifStageRendering": "Rendering",
|
|
164
|
+
"gifStageUploading": "Saving…",
|
|
165
|
+
"imageGif": "Image to GIF",
|
|
166
|
+
"imageGifCancel": "Cancel (the GIF in progress is saved to My images)",
|
|
167
|
+
"imageGifMake": "Make GIF",
|
|
168
|
+
"imageGifMaskFallback": "Couldn't isolate the object, so the effect covers the whole image."
|
|
147
169
|
},
|
|
148
170
|
"gifEffects": {
|
|
149
171
|
"text": {
|
|
@@ -229,6 +251,10 @@
|
|
|
229
251
|
"label": "Holographic foil (gold)",
|
|
230
252
|
"hint": "Sweeps a warm gold sheen over it, like gold leaf."
|
|
231
253
|
}
|
|
254
|
+
},
|
|
255
|
+
"group": {
|
|
256
|
+
"object": "Subject in the image",
|
|
257
|
+
"whole": "Whole image"
|
|
232
258
|
}
|
|
233
259
|
},
|
|
234
260
|
"chart": {
|
|
@@ -284,7 +310,25 @@
|
|
|
284
310
|
"detachHint": "Breaks it into plain shapes and text. After that, values and bars no longer stay in sync.",
|
|
285
311
|
"panelTitle": "Charts",
|
|
286
312
|
"insertHint": "Click to add to the canvas",
|
|
287
|
-
"panelHint": "Drop one in, then edit the values on the right — the bars follow. You can paste a table from Excel."
|
|
313
|
+
"panelHint": "Drop one in, then edit the values on the right — the bars follow. You can paste a table from Excel.",
|
|
314
|
+
"presets": {
|
|
315
|
+
"barHCompare": "Horizontal · Compare",
|
|
316
|
+
"barHMinimal": "Minimal bars",
|
|
317
|
+
"barHPercent": "Horizontal · Percent",
|
|
318
|
+
"barHRank": "Horizontal · Ranking",
|
|
319
|
+
"barVAxis": "Bar with axis",
|
|
320
|
+
"barVCompare": "Vertical · Compare",
|
|
321
|
+
"barVPlain": "Vertical · Plain",
|
|
322
|
+
"barVRank": "Ranked columns",
|
|
323
|
+
"donutShare": "Donut · Share",
|
|
324
|
+
"donutSingle": "Single-ratio donut",
|
|
325
|
+
"gaugeRate": "Gauge · Rate",
|
|
326
|
+
"gaugeScore": "Score gauge",
|
|
327
|
+
"lineCompare": "Comparison line",
|
|
328
|
+
"lineTrend": "Line · Trend",
|
|
329
|
+
"stackMix": "Stacked · Mix",
|
|
330
|
+
"stackShare": "Composition stack"
|
|
331
|
+
}
|
|
288
332
|
},
|
|
289
333
|
"table": {
|
|
290
334
|
"typeTable": "Table",
|
|
@@ -347,6 +391,19 @@
|
|
|
347
391
|
"moveColumn": "Drag to reorder column",
|
|
348
392
|
"removeColumn": "Delete column {{column}}",
|
|
349
393
|
"insertColumn": "Insert column at position {{at}}"
|
|
394
|
+
},
|
|
395
|
+
"preset": {
|
|
396
|
+
"gridCheck": "Checkmark comparison",
|
|
397
|
+
"gridCompare": "Comparison table",
|
|
398
|
+
"gridHeader": "Header table",
|
|
399
|
+
"gridPrice": "Price table",
|
|
400
|
+
"gridZebra": "Zebra comparison",
|
|
401
|
+
"specAiry": "Airy spec table",
|
|
402
|
+
"specBoxed": "Boxed spec table",
|
|
403
|
+
"specCelled": "Celled spec table",
|
|
404
|
+
"specHairline": "Hairline spec table",
|
|
405
|
+
"specRight": "Right-aligned values",
|
|
406
|
+
"typeCentered": "By type"
|
|
350
407
|
}
|
|
351
408
|
},
|
|
352
409
|
"brandAssets": {
|
|
@@ -381,7 +438,11 @@
|
|
|
381
438
|
"notAGif": "Only GIF files can be uploaded.",
|
|
382
439
|
"upload": "Upload GIF",
|
|
383
440
|
"empty": "No GIFs yet. Turn a text, image, or shape into one.",
|
|
384
|
-
"sectionOther": "Other"
|
|
441
|
+
"sectionOther": "Other",
|
|
442
|
+
"sectionImageEffect": "Image GIFs (effect)",
|
|
443
|
+
"sectionImagePrompt": "Image GIFs (prompt)",
|
|
444
|
+
"sectionShape": "Shape GIFs",
|
|
445
|
+
"sectionText": "Text GIFs"
|
|
385
446
|
},
|
|
386
447
|
"text": {
|
|
387
448
|
"heading": "Heading",
|
|
@@ -573,7 +634,9 @@
|
|
|
573
634
|
"next": "Next",
|
|
574
635
|
"replace": "Replace",
|
|
575
636
|
"replaceAll": "Replace all",
|
|
576
|
-
"chartHint": "Text inside charts is redrawn from the data, so it is skipped."
|
|
637
|
+
"chartHint": "Text inside charts is redrawn from the data, so it is skipped.",
|
|
638
|
+
"idle": "Searches the whole document",
|
|
639
|
+
"none": "No matches"
|
|
577
640
|
},
|
|
578
641
|
"pageToolbar": {
|
|
579
642
|
"aiGenerate": "AI generate",
|
|
@@ -744,7 +807,15 @@
|
|
|
744
807
|
"previewAlt": "Code preview",
|
|
745
808
|
"previewEmpty": "Enter content to see a preview here.",
|
|
746
809
|
"insert": "Add to canvas",
|
|
747
|
-
"hint": "Codes are generated right in your browser. No credits are used."
|
|
810
|
+
"hint": "Codes are generated right in your browser. No credits are used.",
|
|
811
|
+
"eanInvalid": "Enter 12 digits and we'll add the check digit. With 13 digits, the check digit must match.",
|
|
812
|
+
"eanLabel": "Product barcode number",
|
|
813
|
+
"eanPlaceholder": "12 or 13 digits",
|
|
814
|
+
"kindEan": "Barcode",
|
|
815
|
+
"kindQr": "QR code",
|
|
816
|
+
"qrInvalid": "The content is too long to encode as a QR code.",
|
|
817
|
+
"valueLabel": "Link or text",
|
|
818
|
+
"valuePlaceholder": "https://…"
|
|
748
819
|
},
|
|
749
820
|
"bubbleTail": {
|
|
750
821
|
"handle": "Drag the tail tip to move it (drag inside the body to remove the tail)"
|
|
@@ -775,10 +846,18 @@
|
|
|
775
846
|
"brandShapes": "Brand shapes",
|
|
776
847
|
"brandReferences": "My references",
|
|
777
848
|
"layers": "Layers",
|
|
778
|
-
"structure": "Structure"
|
|
849
|
+
"structure": "Structure",
|
|
850
|
+
"charts": "Charts",
|
|
851
|
+
"decorations": "Decor",
|
|
852
|
+
"icons": "Icons",
|
|
853
|
+
"qr": "QR",
|
|
854
|
+
"shapes": "Shapes",
|
|
855
|
+
"tables": "Tables"
|
|
779
856
|
},
|
|
780
857
|
"recents": {
|
|
781
|
-
"title": "Favorites & recent"
|
|
858
|
+
"title": "Favorites & recent",
|
|
859
|
+
"pin": "Add to favorites",
|
|
860
|
+
"unpin": "Remove from favorites"
|
|
782
861
|
},
|
|
783
862
|
"crop": {
|
|
784
863
|
"title": "Crop",
|
package/i18n/index.ts
CHANGED
|
@@ -11,6 +11,16 @@ import en from "./en.json";
|
|
|
11
11
|
* **번역기(i18next 인스턴스)는 소비자 것이다.** 패키지가 자기 인스턴스를 만들면
|
|
12
12
|
* 언어 전환·네임스페이스 로딩이 두 벌로 갈라지므로, 여기서는 등록만 한다.
|
|
13
13
|
*/
|
|
14
|
+
/** 언어 → 네임스페이스 → 중첩 문구. 부분만 주면 준 자리만 바뀐다. */
|
|
15
|
+
export type DetailPageEditorTranslationOverrides = Record<
|
|
16
|
+
string,
|
|
17
|
+
Record<string, unknown>
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
export type RegisterTranslationsOptions = {
|
|
21
|
+
overrides?: DetailPageEditorTranslationOverrides;
|
|
22
|
+
};
|
|
23
|
+
|
|
14
24
|
export type I18nLike = {
|
|
15
25
|
addResourceBundle: (
|
|
16
26
|
lng: string,
|
|
@@ -30,8 +40,27 @@ export type I18nLike = {
|
|
|
30
40
|
*
|
|
31
41
|
* 렌더보다 **먼저** 부르면 된다. 늦게 불러도 i18next 가 다시 그리지만, 그 사이 한 번은
|
|
32
42
|
* 키가 보인다.
|
|
43
|
+
*
|
|
44
|
+
* ## 문구를 갈아 끼우고 싶을 때
|
|
45
|
+
*
|
|
46
|
+
* 소비자가 이미 자기 로케일 파일에 같은 키를 갖고 있으면 위의 `overwrite=false` 덕에
|
|
47
|
+
* 저절로 이긴다. 하지만 i18n 라이브러리를 안 쓰는 앱은 그럴 자리가 없다 — 편집기 하나
|
|
48
|
+
* 때문에 로케일 파일 체계를 새로 세우게 된다. 그래서 여기서 직접 받는다.
|
|
49
|
+
*
|
|
50
|
+
* ```ts
|
|
51
|
+
* registerDetailPageEditorTranslations(i18n, {
|
|
52
|
+
* overrides: {
|
|
53
|
+
* ko: { branding: { detailPage: { sidebar: { photos: "이미지" } } } },
|
|
54
|
+
* },
|
|
55
|
+
* });
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* 부분만 준다. 준 자리만 바뀌고 나머지는 우리 번들 그대로다.
|
|
33
59
|
*/
|
|
34
|
-
export function registerDetailPageEditorTranslations(
|
|
60
|
+
export function registerDetailPageEditorTranslations(
|
|
61
|
+
i18n: I18nLike,
|
|
62
|
+
options: RegisterTranslationsOptions = {},
|
|
63
|
+
): void {
|
|
35
64
|
for (const [lng, bundle] of [
|
|
36
65
|
["ko", ko],
|
|
37
66
|
["en", en],
|
|
@@ -40,6 +69,14 @@ export function registerDetailPageEditorTranslations(i18n: I18nLike): void {
|
|
|
40
69
|
i18n.addResourceBundle(lng, ns, resources, true, false);
|
|
41
70
|
}
|
|
42
71
|
}
|
|
72
|
+
|
|
73
|
+
// 덮어쓸 것을 **나중에** 얹는다. 위와 달리 `overwrite=true` 다 — 소비자가 일부러
|
|
74
|
+
// 준 문구가 우리 기본값에 지면 준 의미가 없다.
|
|
75
|
+
for (const [lng, bundle] of Object.entries(options.overrides ?? {})) {
|
|
76
|
+
for (const [ns, resources] of Object.entries(bundle ?? {})) {
|
|
77
|
+
i18n.addResourceBundle(lng, ns, resources, true, true);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
43
80
|
}
|
|
44
81
|
|
|
45
82
|
export { ko as detailPageEditorKo, en as detailPageEditorEn };
|
package/i18n/ko.json
CHANGED
|
@@ -143,7 +143,29 @@
|
|
|
143
143
|
"weight600": "약간 굵게",
|
|
144
144
|
"weight700": "굵게",
|
|
145
145
|
"weight800": "아주 굵게",
|
|
146
|
-
"weight900": "가장 굵게"
|
|
146
|
+
"weight900": "가장 굵게",
|
|
147
|
+
"alignInGroup": "그룹 내 정렬",
|
|
148
|
+
"alignInSection": "섹션 정렬",
|
|
149
|
+
"cellGridCols": "열 수",
|
|
150
|
+
"cellGridGif": "셀 차오름 GIF",
|
|
151
|
+
"cellGridGifHint": "채운 칸이 순서대로 켜진 뒤 2초 멈췄다 반복해요. 축·수치 글자는 캔버스에서 위에 얹으세요.",
|
|
152
|
+
"cellGridGifMake": "차트 GIF 만들기",
|
|
153
|
+
"cellGridInvalid": "행별 칸 수는 1개 이상이고 열 수를 넘을 수 없어요.",
|
|
154
|
+
"cellGridRows": "행별 채울 칸",
|
|
155
|
+
"cellGridShape": "모양",
|
|
156
|
+
"countUpGif": "숫자를 카운트업으로",
|
|
157
|
+
"countUpGifHint": "0에서 {{target}}까지 오른 뒤 2초 멈췄다 반복해요. 이 자리에 그대로 들어갑니다.",
|
|
158
|
+
"countUpGifMake": "카운트업 GIF 만들기",
|
|
159
|
+
"gifShapeUnreadable": "도형을 읽지 못했어요.",
|
|
160
|
+
"gifStageDetecting": "물체를 찾는 중…",
|
|
161
|
+
"gifStageEncoding": "GIF로 굽는 중…",
|
|
162
|
+
"gifStagePreparing": "준비 중…",
|
|
163
|
+
"gifStageRendering": "그리는 중",
|
|
164
|
+
"gifStageUploading": "저장 중…",
|
|
165
|
+
"imageGif": "이미지를 GIF로",
|
|
166
|
+
"imageGifCancel": "취소 (만들던 GIF는 '내 이미지'에 저장돼요)",
|
|
167
|
+
"imageGifMake": "GIF 만들기",
|
|
168
|
+
"imageGifMaskFallback": "물체를 특정하지 못해 이미지 전체에 적용했어요."
|
|
147
169
|
},
|
|
148
170
|
"gifEffects": {
|
|
149
171
|
"text": {
|
|
@@ -229,6 +251,10 @@
|
|
|
229
251
|
"label": "홀로그램 포일 (골드)",
|
|
230
252
|
"hint": "금박 포일처럼 따뜻한 금색 광택이 훑고 지나갑니다."
|
|
231
253
|
}
|
|
254
|
+
},
|
|
255
|
+
"group": {
|
|
256
|
+
"object": "이미지 속 물체",
|
|
257
|
+
"whole": "이미지 전체"
|
|
232
258
|
}
|
|
233
259
|
},
|
|
234
260
|
"chart": {
|
|
@@ -284,7 +310,25 @@
|
|
|
284
310
|
"detachHint": "도형과 글자로 풀어 자유롭게 편집합니다. 푼 뒤에는 값과 막대가 따로 놉니다.",
|
|
285
311
|
"panelTitle": "차트",
|
|
286
312
|
"insertHint": "클릭하여 캔버스에 삽입",
|
|
287
|
-
"panelHint": "놓은 뒤 오른쪽에서 값을 고치면 막대 길이가 따라갑니다. 엑셀에서 복사해 붙여넣을 수도 있어요."
|
|
313
|
+
"panelHint": "놓은 뒤 오른쪽에서 값을 고치면 막대 길이가 따라갑니다. 엑셀에서 복사해 붙여넣을 수도 있어요.",
|
|
314
|
+
"presets": {
|
|
315
|
+
"barHCompare": "가로 막대 · 비교",
|
|
316
|
+
"barHMinimal": "간결 가로 막대",
|
|
317
|
+
"barHPercent": "가로 막대 · 퍼센트",
|
|
318
|
+
"barHRank": "가로 막대 · 순위",
|
|
319
|
+
"barVAxis": "축 있는 막대",
|
|
320
|
+
"barVCompare": "세로 막대 · 비교",
|
|
321
|
+
"barVPlain": "세로 막대 · 기본",
|
|
322
|
+
"barVRank": "순위 세로 막대",
|
|
323
|
+
"donutShare": "도넛 · 구성비",
|
|
324
|
+
"donutSingle": "단일 비율 도넛",
|
|
325
|
+
"gaugeRate": "게이지 · 달성률",
|
|
326
|
+
"gaugeScore": "점수 게이지",
|
|
327
|
+
"lineCompare": "비교 꺾은선",
|
|
328
|
+
"lineTrend": "꺾은선 · 추이",
|
|
329
|
+
"stackMix": "누적 · 구성",
|
|
330
|
+
"stackShare": "구성비 누적"
|
|
331
|
+
}
|
|
288
332
|
},
|
|
289
333
|
"table": {
|
|
290
334
|
"typeTable": "표",
|
|
@@ -347,6 +391,19 @@
|
|
|
347
391
|
"moveColumn": "끌어서 열 순서 바꾸기",
|
|
348
392
|
"removeColumn": "{{column}}열 삭제",
|
|
349
393
|
"insertColumn": "{{at}}번째 자리에 열 추가"
|
|
394
|
+
},
|
|
395
|
+
"preset": {
|
|
396
|
+
"gridCheck": "체크 비교표",
|
|
397
|
+
"gridCompare": "비교표",
|
|
398
|
+
"gridHeader": "머리글 표",
|
|
399
|
+
"gridPrice": "가격표",
|
|
400
|
+
"gridZebra": "줄무늬 비교표",
|
|
401
|
+
"specAiry": "여백형 스펙표",
|
|
402
|
+
"specBoxed": "상자형 스펙표",
|
|
403
|
+
"specCelled": "칸 구분 스펙표",
|
|
404
|
+
"specHairline": "헤어라인 스펙표",
|
|
405
|
+
"specRight": "값 우측정렬",
|
|
406
|
+
"typeCentered": "타입별 안내"
|
|
350
407
|
}
|
|
351
408
|
},
|
|
352
409
|
"brandAssets": {
|
|
@@ -381,7 +438,11 @@
|
|
|
381
438
|
"notAGif": "GIF 파일만 올릴 수 있어요.",
|
|
382
439
|
"upload": "GIF 올리기",
|
|
383
440
|
"empty": "아직 GIF가 없어요. 텍스트·이미지·도형을 골라 GIF로 만들어 보세요.",
|
|
384
|
-
"sectionOther": "기타"
|
|
441
|
+
"sectionOther": "기타",
|
|
442
|
+
"sectionImageEffect": "이미지 GIF (이펙트)",
|
|
443
|
+
"sectionImagePrompt": "이미지 GIF (프롬프트)",
|
|
444
|
+
"sectionShape": "도형 GIF",
|
|
445
|
+
"sectionText": "텍스트 GIF"
|
|
385
446
|
},
|
|
386
447
|
"text": {
|
|
387
448
|
"heading": "제목",
|
|
@@ -573,7 +634,9 @@
|
|
|
573
634
|
"next": "다음",
|
|
574
635
|
"replace": "바꾸기",
|
|
575
636
|
"replaceAll": "모두 바꾸기",
|
|
576
|
-
"chartHint": "차트 안 글자는 값에서 다시 그려지므로 빠집니다."
|
|
637
|
+
"chartHint": "차트 안 글자는 값에서 다시 그려지므로 빠집니다.",
|
|
638
|
+
"idle": "문서 전체에서 찾습니다",
|
|
639
|
+
"none": "없습니다"
|
|
577
640
|
},
|
|
578
641
|
"pageToolbar": {
|
|
579
642
|
"aiGenerate": "AI 생성",
|
|
@@ -744,7 +807,15 @@
|
|
|
744
807
|
"previewAlt": "코드 미리보기",
|
|
745
808
|
"previewEmpty": "내용을 넣으면 여기에 미리보기가 나와요.",
|
|
746
809
|
"insert": "캔버스에 넣기",
|
|
747
|
-
"hint": "코드는 브라우저에서 바로 만들어져요. 크레딧이 들지 않아요."
|
|
810
|
+
"hint": "코드는 브라우저에서 바로 만들어져요. 크레딧이 들지 않아요.",
|
|
811
|
+
"eanInvalid": "12자리를 넣으면 검증번호를 채워 드려요. 13자리면 검증번호가 맞아야 해요.",
|
|
812
|
+
"eanLabel": "상품 바코드 번호",
|
|
813
|
+
"eanPlaceholder": "숫자 12자리 또는 13자리",
|
|
814
|
+
"kindEan": "바코드",
|
|
815
|
+
"kindQr": "QR 코드",
|
|
816
|
+
"qrInvalid": "내용이 너무 길어 QR로 만들 수 없어요.",
|
|
817
|
+
"valueLabel": "링크 또는 텍스트",
|
|
818
|
+
"valuePlaceholder": "https://…"
|
|
748
819
|
},
|
|
749
820
|
"bubbleTail": {
|
|
750
821
|
"handle": "꼬리 끝을 끌어 위치를 바꿉니다 (몸통 안으로 끌면 꼬리가 사라집니다)"
|
|
@@ -775,10 +846,18 @@
|
|
|
775
846
|
"brandShapes": "브랜드 도형",
|
|
776
847
|
"brandReferences": "내 레퍼런스",
|
|
777
848
|
"layers": "레이어",
|
|
778
|
-
"structure": "구조"
|
|
849
|
+
"structure": "구조",
|
|
850
|
+
"charts": "차트",
|
|
851
|
+
"decorations": "장식",
|
|
852
|
+
"icons": "아이콘",
|
|
853
|
+
"qr": "QR",
|
|
854
|
+
"shapes": "도형",
|
|
855
|
+
"tables": "표"
|
|
779
856
|
},
|
|
780
857
|
"recents": {
|
|
781
|
-
"title": "즐겨찾기 · 최근"
|
|
858
|
+
"title": "즐겨찾기 · 최근",
|
|
859
|
+
"pin": "즐겨찾기에 추가",
|
|
860
|
+
"unpin": "즐겨찾기에서 빼기"
|
|
782
861
|
},
|
|
783
862
|
"crop": {
|
|
784
863
|
"title": "자르기",
|
package/index.ts
CHANGED
|
@@ -22,3 +22,19 @@ export type * from "./types/detail-page-api";
|
|
|
22
22
|
export type * from "./types/commerce";
|
|
23
23
|
|
|
24
24
|
export { DetailPageEditor } from "./components/detail-page/detail-page-editor";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 편집기가 부르는 주소(라우트 · 정적 자산)를 앱에 맞춘다.
|
|
28
|
+
*
|
|
29
|
+
* `basePath` 를 쓰는 앱은 **반드시** 불러야 한다 — 루트 절대경로 `fetch` 는 Next 의
|
|
30
|
+
* basePath 를 안 타므로, 안 부르면 아이콘·사진·폰트가 앱 바깥을 두드린다.
|
|
31
|
+
*/
|
|
32
|
+
export {
|
|
33
|
+
configureDetailPageEditor,
|
|
34
|
+
detailPageEditorConfig,
|
|
35
|
+
} from "./lib/detail-page/runtime-config";
|
|
36
|
+
export type {
|
|
37
|
+
DetailPageEditorAssets,
|
|
38
|
+
DetailPageEditorConfig,
|
|
39
|
+
DetailPageEditorEndpoints,
|
|
40
|
+
} from "./lib/detail-page/runtime-config";
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
type CatalogFont,
|
|
16
16
|
} from "@leviosa-ai/konva";
|
|
17
17
|
|
|
18
|
+
import { editorAssetBase } from "../detail-page/runtime-config";
|
|
19
|
+
|
|
18
20
|
export type FontOption = CatalogFont;
|
|
19
21
|
|
|
20
22
|
/**
|
|
@@ -77,11 +79,15 @@ const COMMON_HANGUL_SAMPLE =
|
|
|
77
79
|
* load. Stamping the bundle version into the URL makes each bundle its own cache key,
|
|
78
80
|
* so a deploy can never be shadowed by the previous bundle's stylesheet.
|
|
79
81
|
*/
|
|
82
|
+
/**
|
|
83
|
+
* 번들의 뿌리는 설정에서 온다(`configureDetailPageEditor`). 기본값은 예전과 같은
|
|
84
|
+
* `/render-fonts` 라, 아무것도 안 부른 소비자에게는 주소가 그대로다.
|
|
85
|
+
*/
|
|
80
86
|
function bundleUrl(path: string): string {
|
|
81
|
-
return `${path}?v=${LEVIOSA_KONVA_VERSION}`;
|
|
87
|
+
return `${editorAssetBase("fontBundle")}${path}?v=${LEVIOSA_KONVA_VERSION}`;
|
|
82
88
|
}
|
|
83
89
|
|
|
84
|
-
const
|
|
90
|
+
const localFontCssUrl = () => bundleUrl("/font-css.css");
|
|
85
91
|
|
|
86
92
|
export class FontLoadError extends Error {
|
|
87
93
|
constructor(message: string) {
|
|
@@ -139,23 +145,23 @@ function ensureFontFaceStylesheets(
|
|
|
139
145
|
for (const { family, weight } of requests) {
|
|
140
146
|
const slug = slugifyFamily(family.trim());
|
|
141
147
|
if (!slug || !LOCAL_FONT_FAMILY_SLUGS.has(slug)) {
|
|
142
|
-
hrefs.push(
|
|
148
|
+
hrefs.push(localFontCssUrl());
|
|
143
149
|
continue;
|
|
144
150
|
}
|
|
145
151
|
const key = `${slug}\n${weight}`;
|
|
146
152
|
if (seen.has(key)) continue;
|
|
147
153
|
seen.add(key);
|
|
148
|
-
hrefs.push(bundleUrl(`/
|
|
154
|
+
hrefs.push(bundleUrl(`/family-css/${slug}-${weight}.css`));
|
|
149
155
|
}
|
|
150
156
|
return Promise.all(
|
|
151
157
|
hrefs.map((href) =>
|
|
152
158
|
ensureStylesheet(href).catch(() => {
|
|
153
159
|
const slug = href.match(/family-css\/(.+)-\d+\.css\?/)?.[1];
|
|
154
160
|
return slug
|
|
155
|
-
? ensureStylesheet(bundleUrl(`/
|
|
156
|
-
ensureStylesheet(
|
|
161
|
+
? ensureStylesheet(bundleUrl(`/family-css/${slug}.css`)).catch(() =>
|
|
162
|
+
ensureStylesheet(localFontCssUrl()),
|
|
157
163
|
)
|
|
158
|
-
: ensureStylesheet(
|
|
164
|
+
: ensureStylesheet(localFontCssUrl());
|
|
159
165
|
}),
|
|
160
166
|
),
|
|
161
167
|
).then(() => {});
|
|
@@ -285,7 +291,7 @@ export function prefetchFontVariants(requests: FontFaceRequest[]): void {
|
|
|
285
291
|
* loads the font faces it uses through `loadFontFaces`.
|
|
286
292
|
*/
|
|
287
293
|
export function loadAllGoogleFonts(): Promise<void> {
|
|
288
|
-
return ensureStylesheet(
|
|
294
|
+
return ensureStylesheet(localFontCssUrl()).catch(() => {});
|
|
289
295
|
}
|
|
290
296
|
|
|
291
297
|
export function loadDefaultCardnewsFonts(): Promise<void> {
|
|
@@ -26,6 +26,10 @@ import { shrinkReferenceDataUri } from "./reference-image";
|
|
|
26
26
|
* ``content`` 는 **문구가 아니라 정보 구성**이다 — 어떤 항목을 어떤 순서로 다루는지
|
|
27
27
|
* (성분표 · 사용법 · 비교표 · 후기). 남의 문장을 옮겨 오는 축이 아니라서 설명도 그렇게
|
|
28
28
|
* 적는다. 셀러가 "내용을 참고"를 "문구를 베낀다"로 읽으면 안 된다.
|
|
29
|
+
*
|
|
30
|
+
* ``imagery`` 는 **사진 연출**이다 — 레퍼런스에 실린 사진들의 피사체 연출·구도·소품·
|
|
31
|
+
* 조명·톤을 읽어, 이 상품의 이미지 생성 프롬프트를 그 화법으로 쓰게 한다. 남의 제품·
|
|
32
|
+
* 인물을 옮겨 그리는 축이 아니다(연출만 가져간다).
|
|
29
33
|
*/
|
|
30
34
|
export const DESIGN_REFERENCE_ASPECTS = [
|
|
31
35
|
{ key: "palette", label: "색감", hint: "바탕·글자·강조의 색 관계" },
|
|
@@ -34,6 +38,7 @@ export const DESIGN_REFERENCE_ASPECTS = [
|
|
|
34
38
|
{ key: "content", label: "내용 구성", hint: "다루는 정보 항목과 순서" },
|
|
35
39
|
{ key: "mood", label: "분위기", hint: "여백의 양과 요소 밀도" },
|
|
36
40
|
{ key: "decoration", label: "장식", hint: "구분선·도형의 성격" },
|
|
41
|
+
{ key: "imagery", label: "이미지", hint: "사진 연출 — 구도·소품·조명·톤" },
|
|
37
42
|
] as const;
|
|
38
43
|
|
|
39
44
|
export type DesignReferenceAspect = (typeof DESIGN_REFERENCE_ASPECTS)[number]["key"];
|
package/lib/detail-page/icons.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 아이콘 검색 — 편집기 "요소 · 아이콘" 그룹이 쓰는 얇은 층.
|
|
3
3
|
*
|
|
4
|
-
* 검색은 우리
|
|
5
|
-
*
|
|
4
|
+
* 검색은 우리 서버를 거친다. 이유는 그 라우트 주석에 있다(라이선스 게이트 · 한국어 확장 ·
|
|
5
|
+
* 마크업 배치). 라우트는 패키지가 들고 있고(`@leviosa-ai/detail-page-editor/server/icons`)
|
|
6
|
+
* 주소는 `configureDetailPageEditor` 가 정한다.
|
|
6
7
|
*
|
|
7
8
|
* 사진과 달리 **S3로 옮겨 담지 않는다.** 아이콘은 삽입하는 순간 마크업이 문서 안에
|
|
8
9
|
* 박히므로 저장된 상세페이지가 제공처 수명에 묶이지 않는다.
|
|
9
10
|
*/
|
|
10
11
|
|
|
12
|
+
import { editorEndpoint } from "./runtime-config";
|
|
11
13
|
import type { IconStyle } from "./icon-search";
|
|
12
14
|
|
|
13
15
|
export type { IconStyle };
|
|
@@ -61,7 +63,10 @@ export async function searchIcons({
|
|
|
61
63
|
if (style) params.set("style", style);
|
|
62
64
|
if (page > 0) params.set("page", String(page));
|
|
63
65
|
|
|
64
|
-
const response = await fetch(
|
|
66
|
+
const response = await fetch(
|
|
67
|
+
`${editorEndpoint("icons")}?${params.toString()}`,
|
|
68
|
+
{ signal },
|
|
69
|
+
);
|
|
65
70
|
if (!response.ok) throw new Error(`icons ${response.status}`);
|
|
66
71
|
return (await response.json()) as IconSearchResponse;
|
|
67
72
|
}
|