@leviosa-ai/detail-page-editor 0.16.2 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/detail-page/ai-generate-panel.tsx +44 -9
- package/components/detail-page/annotation-canvas.tsx +18 -13
- package/components/detail-page/chart-inspector.tsx +5 -0
- package/components/detail-page/confirm-delete-button.tsx +82 -0
- package/components/detail-page/design-reference-picker.tsx +47 -46
- package/components/detail-page/detail-page-download-dialog.tsx +117 -18
- package/components/detail-page/detail-page-editor.tsx +65 -15
- package/components/detail-page/detail-page-host-context.tsx +36 -2
- package/components/detail-page/detail-page-layers-panel.tsx +125 -21
- package/components/detail-page/detail-page-my-images-panel.tsx +24 -14
- package/components/detail-page/detail-page-my-shapes-panel.tsx +7 -6
- package/components/detail-page/detail-page-pages-panel.tsx +12 -9
- package/components/detail-page/detail-page-properties-panel.tsx +338 -1040
- package/components/detail-page/editor-hotkeys.tsx +57 -9
- package/components/detail-page/find-replace-panel.tsx +5 -2
- package/components/detail-page/group-prompt-edit-panel.tsx +16 -8
- package/components/detail-page/inspector-controls.tsx +106 -7
- package/components/detail-page/inspectors/gif-sections.tsx +912 -0
- package/components/detail-page/inspectors/shared.ts +74 -0
- package/components/detail-page/leviosa-canvas-workspace.tsx +95 -0
- package/components/detail-page/prompt-edit-panel.tsx +3 -1
- package/components/detail-page/table-inspector.tsx +24 -3
- package/components/detail-page/use-auto-save.ts +118 -47
- package/i18n/en.json +127 -8
- package/i18n/ko.json +127 -8
- package/index.ts +2 -0
- package/lib/detail-page/canvas-paste.ts +77 -0
- package/lib/detail-page/data-gif-payload.ts +2 -0
- package/lib/detail-page/design-reference.ts +2 -2
- package/lib/detail-page/editor-profile.ts +3 -3
- package/lib/detail-page/export-platforms.ts +11 -0
- package/lib/detail-page/group-action.ts +32 -19
- package/lib/detail-page/layer-move.ts +89 -113
- package/lib/detail-page/shape-to-image.ts +6 -4
- package/lib/detail-page/text-gif-layout.ts +16 -11
- package/lib/detail-page-canvas/export/ai.ts +29 -3
- package/lib/detail-page-canvas/export/document-model.ts +9 -3
- package/lib/detail-page-canvas/export/export-files.ts +56 -10
- package/lib/detail-page-canvas/export/frame.ts +83 -0
- package/lib/detail-page-canvas/export/psd.ts +93 -33
- package/lib/detail-page-canvas/export/raster.ts +35 -6
- package/lib/detail-page-canvas/export/svg.ts +97 -7
- package/lib/detail-page-canvas/export/text-layout.ts +35 -15
- package/package.json +2 -2
- package/types/detail-page-canvas.ts +5 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Copyright © 2026 주식회사레비오사에이아이. All rights reserved. See LICENSE.
|
|
2
2
|
"use client";
|
|
3
3
|
|
|
4
|
-
import { useCallback, useMemo, useState, type ChangeEvent } from "react";
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
|
5
5
|
import { ImagePlus, Info, Loader2, Pencil, Sparkles, X } from "lucide-react";
|
|
6
6
|
import { useTranslation } from "react-i18next";
|
|
7
7
|
|
|
@@ -70,6 +70,8 @@ export type GenerateImageInput = {
|
|
|
70
70
|
* 막혀도 이미지는 만들어졌는데 브랜드 버킷엔 아무것도 안 남았다.
|
|
71
71
|
*/
|
|
72
72
|
brandId?: string;
|
|
73
|
+
/** 사용자가 취소하면 끊긴다. 호스트는 요청에 그대로 넘기면 된다. */
|
|
74
|
+
signal?: AbortSignal;
|
|
73
75
|
};
|
|
74
76
|
|
|
75
77
|
export type GenerateImageFn = (input: GenerateImageInput) => Promise<string[]>;
|
|
@@ -81,6 +83,8 @@ export type GenerateGifInput = {
|
|
|
81
83
|
transparent: boolean;
|
|
82
84
|
/** 이미지와 같다. 저장 위치는 start 시점에 확정된다. */
|
|
83
85
|
brandId?: string;
|
|
86
|
+
/** 사용자가 취소하면 끊긴다. */
|
|
87
|
+
signal?: AbortSignal;
|
|
84
88
|
};
|
|
85
89
|
|
|
86
90
|
export type GenerateGifFn = (input: GenerateGifInput) => Promise<string[]>;
|
|
@@ -299,7 +303,10 @@ export function AiGeneratePanel({
|
|
|
299
303
|
onBuyCredits,
|
|
300
304
|
}: AiGeneratePanelProps) {
|
|
301
305
|
const { t } = useTranslation("branding");
|
|
302
|
-
const { brand } = useDetailPageHost();
|
|
306
|
+
const { brand, api } = useDetailPageHost();
|
|
307
|
+
// 진행 중 생성의 취소 손잡이. 패널이 닫혀도 끊는다.
|
|
308
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
309
|
+
useEffect(() => () => abortRef.current?.abort(), []);
|
|
303
310
|
const [mode, setMode] = useState<"image" | "gif">(
|
|
304
311
|
// GIF 생성이 배선된 경우에만 gif 초기값을 존중(미배선이면 토글이 없어 갇힘).
|
|
305
312
|
initialMode === "gif" && onGenerateGif ? "gif" : "image",
|
|
@@ -357,7 +364,9 @@ export function AiGeneratePanel({
|
|
|
357
364
|
try {
|
|
358
365
|
setRefUrl(await uploadFile(file));
|
|
359
366
|
} catch (err) {
|
|
360
|
-
|
|
367
|
+
// 원문은 콘솔로만. 서버·네트워크 문구를 그대로 띄우면 번역도 안 되고 읽히지도 않는다.
|
|
368
|
+
console.error("Reference upload failed", err);
|
|
369
|
+
setError(t("detailPage.aiGenerate.uploadFailed"));
|
|
361
370
|
} finally {
|
|
362
371
|
setRefUploading(false);
|
|
363
372
|
}
|
|
@@ -384,6 +393,9 @@ export function AiGeneratePanel({
|
|
|
384
393
|
setLoading(true);
|
|
385
394
|
setError(null);
|
|
386
395
|
setInsufficient(false);
|
|
396
|
+
const controller = new AbortController();
|
|
397
|
+
abortRef.current = controller;
|
|
398
|
+
const { signal } = controller;
|
|
387
399
|
try {
|
|
388
400
|
const referenceImages = refUrl ? [refUrl] : [];
|
|
389
401
|
const brandId = brand.getStoredActiveBrandId() ?? undefined;
|
|
@@ -393,6 +405,7 @@ export function AiGeneratePanel({
|
|
|
393
405
|
referenceImages,
|
|
394
406
|
transparent,
|
|
395
407
|
brandId,
|
|
408
|
+
signal,
|
|
396
409
|
})
|
|
397
410
|
: await onGenerate!({
|
|
398
411
|
prompt: finalPrompt,
|
|
@@ -401,7 +414,10 @@ export function AiGeneratePanel({
|
|
|
401
414
|
tier: activeTier,
|
|
402
415
|
brandId,
|
|
403
416
|
annotatedImage,
|
|
417
|
+
signal,
|
|
404
418
|
});
|
|
419
|
+
// 취소했으면 늦게 온 결과를 넣지 않는다(호스트가 신호를 무시했어도).
|
|
420
|
+
if (signal.aborted) return;
|
|
405
421
|
const url = urls[0];
|
|
406
422
|
if (url) {
|
|
407
423
|
// GIF는 항상 페이지에 삽입(우측 이미지 교체 대상 아님) + 애니 태깅.
|
|
@@ -415,18 +431,28 @@ export function AiGeneratePanel({
|
|
|
415
431
|
: t("detailPage.aiGenerate.noImage"),
|
|
416
432
|
);
|
|
417
433
|
} catch (err) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
434
|
+
if (signal.aborted) return; // 사용자가 끊었다 — 오류가 아니다.
|
|
435
|
+
// 크레딧 부족은 호스트 판별기(402 본문)로 먼저 가리고, 생성기가 직접 붙인
|
|
436
|
+
// `insufficientCredits` 표시도 계속 받는다(우측 편집 경로가 그렇게 던진다).
|
|
437
|
+
const marked = (err as { insufficientCredits?: boolean } | null)?.insufficientCredits;
|
|
438
|
+
if (api.asInsufficientCreditsError(err) || marked) {
|
|
421
439
|
setInsufficient(true);
|
|
422
440
|
setError(t("detailPage.aiGenerate.insufficientCredits"));
|
|
423
441
|
} else {
|
|
424
|
-
|
|
442
|
+
console.error("AI generation failed", err);
|
|
443
|
+
setError(t("detailPage.aiGenerate.generateFailed"));
|
|
425
444
|
}
|
|
426
445
|
} finally {
|
|
427
|
-
|
|
446
|
+
if (abortRef.current === controller) abortRef.current = null;
|
|
447
|
+
if (!signal.aborted) setLoading(false);
|
|
428
448
|
}
|
|
429
|
-
}, [prompt, loading, blocked, isGif, gifMissingRef, transparent, onGenerate, onGenerateGif, refUrl, store, onResult, activeTier, t]);
|
|
449
|
+
}, [prompt, loading, blocked, isGif, gifMissingRef, transparent, onGenerate, onGenerateGif, refUrl, store, onResult, activeTier, t, api, brand]);
|
|
450
|
+
|
|
451
|
+
const cancelGenerate = useCallback(() => {
|
|
452
|
+
abortRef.current?.abort();
|
|
453
|
+
abortRef.current = null;
|
|
454
|
+
setLoading(false);
|
|
455
|
+
}, []);
|
|
430
456
|
|
|
431
457
|
const handleGenerate = useCallback(() => {
|
|
432
458
|
void runGenerate();
|
|
@@ -760,6 +786,15 @@ export function AiGeneratePanel({
|
|
|
760
786
|
) : null}
|
|
761
787
|
</span>
|
|
762
788
|
</button>
|
|
789
|
+
{loading ? (
|
|
790
|
+
<button
|
|
791
|
+
type="button"
|
|
792
|
+
onClick={cancelGenerate}
|
|
793
|
+
className="h-8 rounded-le-md border border-le-ink-200 text-xs font-le-medium text-le-ink-600 hover:bg-le-ink-50"
|
|
794
|
+
>
|
|
795
|
+
{t("detailPage.aiGenerate.cancel")}
|
|
796
|
+
</button>
|
|
797
|
+
) : null}
|
|
763
798
|
|
|
764
799
|
<AnnotationDialog
|
|
765
800
|
open={annotateOpen}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
Type,
|
|
20
20
|
Undo2,
|
|
21
21
|
} from "lucide-react";
|
|
22
|
+
import { useTranslation } from "react-i18next";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* 그림으로 가리키는 편집 — 이미지·화면 위에 덧그리는 주석 캔버스.
|
|
@@ -120,7 +121,7 @@ function loadImage(src: string): Promise<HTMLImageElement> {
|
|
|
120
121
|
// 동일 출처 프록시를 타는 경우에도 캔버스 오염을 막으려면 명시해야 한다.
|
|
121
122
|
img.crossOrigin = "anonymous";
|
|
122
123
|
img.onload = () => resolve(img);
|
|
123
|
-
img.onerror = () => reject(new Error(
|
|
124
|
+
img.onerror = () => reject(new Error(`annotation image failed to load: ${src.slice(0, 64)}`));
|
|
124
125
|
img.src = src;
|
|
125
126
|
});
|
|
126
127
|
}
|
|
@@ -262,6 +263,10 @@ export const AnnotationCanvas = forwardRef<
|
|
|
262
263
|
{ imageUrl, maxEdge = 2048, onChange, labels },
|
|
263
264
|
ref,
|
|
264
265
|
) {
|
|
266
|
+
const { t } = useTranslation("branding");
|
|
267
|
+
// 라벨을 안 넘겨도 번역된 기본값이 나온다. 넘기면 그것이 이긴다.
|
|
268
|
+
const label = (key: Tool | "color" | "undo" | "redo" | "note") =>
|
|
269
|
+
labels?.[key] ?? t(`detailPage.annotate.tools.${key}`);
|
|
265
270
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
266
271
|
const imgRef = useRef<HTMLImageElement>(null);
|
|
267
272
|
const svgRef = useRef<SVGSVGElement>(null);
|
|
@@ -667,12 +672,12 @@ export const AnnotationCanvas = forwardRef<
|
|
|
667
672
|
const selectedBox = selected ? annotationBox(selected) : null;
|
|
668
673
|
|
|
669
674
|
const TOOLS: [Tool, string, React.ReactNode][] = [
|
|
670
|
-
["pen",
|
|
671
|
-
["rect",
|
|
672
|
-
["arrow",
|
|
673
|
-
["text",
|
|
674
|
-
["eraser",
|
|
675
|
-
["select",
|
|
675
|
+
["pen", label("pen"), <Pencil key="p" size={16} />],
|
|
676
|
+
["rect", label("rect"), <Square key="r" size={16} />],
|
|
677
|
+
["arrow", label("arrow"), <ArrowUpRight key="a" size={16} />],
|
|
678
|
+
["text", label("text"), <Type key="t" size={16} />],
|
|
679
|
+
["eraser", label("eraser"), <Eraser key="e" size={16} />],
|
|
680
|
+
["select", label("select"), <MousePointer2 key="s" size={16} />],
|
|
676
681
|
];
|
|
677
682
|
|
|
678
683
|
return (
|
|
@@ -766,7 +771,7 @@ export const AnnotationCanvas = forwardRef<
|
|
|
766
771
|
<input
|
|
767
772
|
autoFocus
|
|
768
773
|
value={textEditor.value}
|
|
769
|
-
placeholder={
|
|
774
|
+
placeholder={label("note")}
|
|
770
775
|
onChange={(e) =>
|
|
771
776
|
setTextEditor({ ...textEditor, value: e.target.value })
|
|
772
777
|
}
|
|
@@ -817,7 +822,7 @@ export const AnnotationCanvas = forwardRef<
|
|
|
817
822
|
<button
|
|
818
823
|
key={c}
|
|
819
824
|
type="button"
|
|
820
|
-
aria-label={`${
|
|
825
|
+
aria-label={`${label("color")} ${c}`}
|
|
821
826
|
aria-pressed={color === c}
|
|
822
827
|
onClick={() => setColor(c)}
|
|
823
828
|
className="flex h-8 w-6 items-center justify-center"
|
|
@@ -835,8 +840,8 @@ export const AnnotationCanvas = forwardRef<
|
|
|
835
840
|
<span className="mx-1 h-5 w-px bg-le-ink-200" />
|
|
836
841
|
<button
|
|
837
842
|
type="button"
|
|
838
|
-
title={
|
|
839
|
-
aria-label={
|
|
843
|
+
title={label("undo")}
|
|
844
|
+
aria-label={label("undo")}
|
|
840
845
|
disabled={!history.past.length}
|
|
841
846
|
onClick={undo}
|
|
842
847
|
className="flex h-8 w-8 items-center justify-center rounded-le-lg text-le-ink-500 transition-colors hover:bg-le-ink-100 hover:text-le-ink-900 disabled:cursor-not-allowed disabled:opacity-30"
|
|
@@ -845,8 +850,8 @@ export const AnnotationCanvas = forwardRef<
|
|
|
845
850
|
</button>
|
|
846
851
|
<button
|
|
847
852
|
type="button"
|
|
848
|
-
title={
|
|
849
|
-
aria-label={
|
|
853
|
+
title={label("redo")}
|
|
854
|
+
aria-label={label("redo")}
|
|
850
855
|
disabled={!history.future.length}
|
|
851
856
|
onClick={redo}
|
|
852
857
|
className="flex h-8 w-8 items-center justify-center rounded-le-lg text-le-ink-500 transition-colors hover:bg-le-ink-100 hover:text-le-ink-900 disabled:cursor-not-allowed disabled:opacity-30"
|
|
@@ -11,6 +11,7 @@ import { SpecPromptEditPanel } from "./spec-prompt-edit-panel";
|
|
|
11
11
|
import type { SpecEditPrompting } from "./table-inspector";
|
|
12
12
|
import {
|
|
13
13
|
NumberField,
|
|
14
|
+
historyOf,
|
|
14
15
|
Section,
|
|
15
16
|
ToggleButton,
|
|
16
17
|
} from "./inspector-controls";
|
|
@@ -336,6 +337,7 @@ export const ChartInspector = observer(function ChartInspector({
|
|
|
336
337
|
{t("detailPage.chart.decimals")}
|
|
337
338
|
</span>
|
|
338
339
|
<NumberField
|
|
340
|
+
history={historyOf(store)}
|
|
339
341
|
value={spec.options.decimals}
|
|
340
342
|
min={0}
|
|
341
343
|
max={3}
|
|
@@ -389,6 +391,7 @@ export const ChartInspector = observer(function ChartInspector({
|
|
|
389
391
|
</button>
|
|
390
392
|
) : (
|
|
391
393
|
<NumberField
|
|
394
|
+
history={historyOf(store)}
|
|
392
395
|
value={spec.options.max}
|
|
393
396
|
min={1}
|
|
394
397
|
onChange={(max) => setOptions({ max })}
|
|
@@ -496,6 +499,7 @@ export const ChartInspector = observer(function ChartInspector({
|
|
|
496
499
|
{t("detailPage.chart.barSize")}
|
|
497
500
|
</span>
|
|
498
501
|
<NumberField
|
|
502
|
+
history={historyOf(store)}
|
|
499
503
|
value={spec.style.barSize}
|
|
500
504
|
min={1}
|
|
501
505
|
onChange={(barSize) => setStyle({ barSize })}
|
|
@@ -506,6 +510,7 @@ export const ChartInspector = observer(function ChartInspector({
|
|
|
506
510
|
{t("detailPage.chart.gap")}
|
|
507
511
|
</span>
|
|
508
512
|
<NumberField
|
|
513
|
+
history={historyOf(store)}
|
|
509
514
|
value={spec.style.gap}
|
|
510
515
|
min={0}
|
|
511
516
|
onChange={(gap) => setStyle({ gap })}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Copyright © 2026 주식회사레비오사에이아이. All rights reserved. See LICENSE.
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import { useState, type ReactNode } from "react";
|
|
5
|
+
import { useTranslation } from "react-i18next";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 지우기 전에 한 번 묻는 버튼.
|
|
9
|
+
*
|
|
10
|
+
* 서버 자산·화면처럼 되돌릴 수 없는(또는 되돌리기 번거로운) 것을 한 번 눌러 지우면
|
|
11
|
+
* 휴지통 아이콘을 스치기만 해도 사라진다. `window.confirm` 은 쓰지 않는다 — 앱 크롬
|
|
12
|
+
* 밖의 OS 대화상자가 뜨고, 호스트에 확인 대화상자 계약도 없다. 대신 그 자리에서
|
|
13
|
+
* «삭제? 확인 / 취소» 로 바뀐다.
|
|
14
|
+
*/
|
|
15
|
+
export function ConfirmDeleteButton({
|
|
16
|
+
label,
|
|
17
|
+
onConfirm,
|
|
18
|
+
disabled,
|
|
19
|
+
className,
|
|
20
|
+
confirmClassName = "",
|
|
21
|
+
children,
|
|
22
|
+
}: {
|
|
23
|
+
/** 휴지통 버튼의 이름(aria-label·title). */
|
|
24
|
+
label: string;
|
|
25
|
+
onConfirm: () => void;
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
className?: string;
|
|
28
|
+
/** 묻는 동안의 자리. 평소 버튼이 호버에만 보이는 자리라면 여기서 늘 보이게 한다. */
|
|
29
|
+
confirmClassName?: string;
|
|
30
|
+
children: ReactNode;
|
|
31
|
+
}) {
|
|
32
|
+
const { t } = useTranslation("branding");
|
|
33
|
+
const [asking, setAsking] = useState(false);
|
|
34
|
+
|
|
35
|
+
if (!asking) {
|
|
36
|
+
return (
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
aria-label={label}
|
|
40
|
+
title={label}
|
|
41
|
+
disabled={disabled}
|
|
42
|
+
onClick={(event) => {
|
|
43
|
+
event.stopPropagation();
|
|
44
|
+
setAsking(true);
|
|
45
|
+
}}
|
|
46
|
+
className={className}
|
|
47
|
+
>
|
|
48
|
+
{children}
|
|
49
|
+
</button>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<span
|
|
55
|
+
role="group"
|
|
56
|
+
aria-label={label}
|
|
57
|
+
// 줄·카드 자체의 클릭(선택·삽입)으로 새지 않게 막는다.
|
|
58
|
+
onClick={(event) => event.stopPropagation()}
|
|
59
|
+
className={`flex items-center gap-1 rounded-le-md border border-le-ink-200 bg-le-surface px-1.5 py-0.5 text-[11px] shadow-sm ${confirmClassName}`}
|
|
60
|
+
>
|
|
61
|
+
<span className="text-le-ink-600">{t("detailPage.confirmDelete.ask")}</span>
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
autoFocus
|
|
65
|
+
onClick={() => {
|
|
66
|
+
setAsking(false);
|
|
67
|
+
onConfirm();
|
|
68
|
+
}}
|
|
69
|
+
className="font-le-semibold text-le-danger-600 hover:underline"
|
|
70
|
+
>
|
|
71
|
+
{t("detailPage.confirmDelete.confirm")}
|
|
72
|
+
</button>
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
onClick={() => setAsking(false)}
|
|
76
|
+
className="text-le-ink-500 hover:underline"
|
|
77
|
+
>
|
|
78
|
+
{t("detailPage.confirmDelete.cancel")}
|
|
79
|
+
</button>
|
|
80
|
+
</span>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { useCallback, useRef, useState } from "react";
|
|
5
5
|
import { ImagePlus, Loader2, X } from "lucide-react";
|
|
6
|
+
import { useTranslation } from "react-i18next";
|
|
6
7
|
|
|
7
8
|
import {
|
|
8
9
|
DESIGN_REFERENCE_ASPECTS,
|
|
@@ -12,7 +13,6 @@ import {
|
|
|
12
13
|
finalizeReferenceDataUri,
|
|
13
14
|
planReferenceTokens,
|
|
14
15
|
readReferenceFile,
|
|
15
|
-
referenceOrdinal,
|
|
16
16
|
type DesignReferenceAspect,
|
|
17
17
|
} from "../../lib/detail-page/design-reference";
|
|
18
18
|
import { useDetailPageHost } from "./detail-page-host-context";
|
|
@@ -81,7 +81,9 @@ export function DesignReferencePicker({
|
|
|
81
81
|
}[];
|
|
82
82
|
disabled?: boolean;
|
|
83
83
|
}) {
|
|
84
|
+
const { t } = useTranslation("branding");
|
|
84
85
|
const { api } = useDetailPageHost();
|
|
86
|
+
const ordinal = (index: number) => t("detailPage.designReference.ordinal", { n: index + 1 });
|
|
85
87
|
const [items, setItems] = useState<Picked[]>(() =>
|
|
86
88
|
(initialReferences ?? [])
|
|
87
89
|
.slice(0, MAX_DESIGN_REFERENCES)
|
|
@@ -120,7 +122,12 @@ export function DesignReferencePicker({
|
|
|
120
122
|
height: 0,
|
|
121
123
|
});
|
|
122
124
|
} catch (err) {
|
|
123
|
-
|
|
125
|
+
// 거절 사유(형식·크기)는 lib 가 사람 말로 적어 던진다 — 그건 그대로 보여 준다.
|
|
126
|
+
setError(
|
|
127
|
+
err instanceof Error && err.message
|
|
128
|
+
? err.message
|
|
129
|
+
: t("detailPage.designReference.attachFailed"),
|
|
130
|
+
);
|
|
124
131
|
}
|
|
125
132
|
}
|
|
126
133
|
if (!added.length) return;
|
|
@@ -153,7 +160,7 @@ export function DesignReferencePicker({
|
|
|
153
160
|
});
|
|
154
161
|
}
|
|
155
162
|
},
|
|
156
|
-
[items.length],
|
|
163
|
+
[items.length, t],
|
|
157
164
|
);
|
|
158
165
|
|
|
159
166
|
const toggleAspect = useCallback((id: string, aspect: DesignReferenceAspect) => {
|
|
@@ -207,25 +214,28 @@ export function DesignReferencePicker({
|
|
|
207
214
|
const shortfall = api.asInsufficientCreditsError(err);
|
|
208
215
|
setError(
|
|
209
216
|
shortfall
|
|
210
|
-
?
|
|
211
|
-
|
|
212
|
-
|
|
217
|
+
? t("detailPage.designReference.insufficient", {
|
|
218
|
+
count: items.length,
|
|
219
|
+
credits,
|
|
220
|
+
remaining: shortfall.remaining,
|
|
221
|
+
})
|
|
222
|
+
: err instanceof Error && err.message
|
|
213
223
|
? err.message
|
|
214
|
-
: "
|
|
224
|
+
: t("detailPage.designReference.readFailed"),
|
|
215
225
|
);
|
|
216
226
|
} finally {
|
|
217
227
|
setBusy(false);
|
|
218
228
|
}
|
|
219
|
-
}, [api, busy, credits, instruction, items, onBriefChange]);
|
|
229
|
+
}, [api, busy, credits, instruction, items, onBriefChange, t]);
|
|
220
230
|
|
|
221
231
|
return (
|
|
222
232
|
<div className="space-y-3 rounded-le-lg bg-le-ink-50 p-3">
|
|
223
233
|
<div>
|
|
224
|
-
<p className="text-xs font-le-medium text-le-ink-700"
|
|
234
|
+
<p className="text-xs font-le-medium text-le-ink-700">
|
|
235
|
+
{t("detailPage.designReference.title")}
|
|
236
|
+
</p>
|
|
225
237
|
<p className="mt-1 text-[11px] text-le-ink-500">
|
|
226
|
-
|
|
227
|
-
들어가지 않고, 구조와 색을 고르는 근거로만 써요. 최대{" "}
|
|
228
|
-
{MAX_DESIGN_REFERENCES}장.
|
|
238
|
+
{t("detailPage.designReference.description", { max: MAX_DESIGN_REFERENCES })}
|
|
229
239
|
</p>
|
|
230
240
|
</div>
|
|
231
241
|
|
|
@@ -238,15 +248,15 @@ export function DesignReferencePicker({
|
|
|
238
248
|
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-le-md bg-le-ink-100">
|
|
239
249
|
{/* 붙인 사진은 data URI라 next/image 로 최적화할 것이 없다. */}
|
|
240
250
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
241
|
-
<img src={item.uri} alt="
|
|
251
|
+
<img src={item.uri} alt={t("detailPage.designReference.title")} className="h-full w-full object-cover" />
|
|
242
252
|
<span className="absolute left-0.5 top-0.5 rounded bg-le-ink-900/75 px-1 text-[10px] font-le-semibold leading-4 text-le-on-accent">
|
|
243
|
-
{
|
|
253
|
+
{ordinal(index)}
|
|
244
254
|
</span>
|
|
245
255
|
<button
|
|
246
256
|
type="button"
|
|
247
257
|
disabled={disabled || busy}
|
|
248
258
|
onClick={() => remove(item.id)}
|
|
249
|
-
aria-label={
|
|
259
|
+
aria-label={t("detailPage.designReference.remove", { ordinal: ordinal(index) })}
|
|
250
260
|
className="absolute right-0.5 top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-le-ink-900/70 text-le-on-accent disabled:opacity-40"
|
|
251
261
|
>
|
|
252
262
|
<X size={10} />
|
|
@@ -262,7 +272,7 @@ export function DesignReferencePicker({
|
|
|
262
272
|
disabled={disabled || busy}
|
|
263
273
|
aria-pressed={on}
|
|
264
274
|
// 축 이름만으로는 "내용 구성"이 문구까지인지 알 수 없다.
|
|
265
|
-
title={aspect.hint}
|
|
275
|
+
title={t(`detailPage.designReference.aspects.${aspect.key}.hint`)}
|
|
266
276
|
onClick={() => toggleAspect(item.id, aspect.key)}
|
|
267
277
|
className={`rounded-full border px-2 py-0.5 text-[11px] transition-colors disabled:opacity-40 ${
|
|
268
278
|
on
|
|
@@ -270,7 +280,7 @@ export function DesignReferencePicker({
|
|
|
270
280
|
: "border-le-ink-200 text-le-ink-500 hover:border-le-ink-400 hover:text-le-ink-700"
|
|
271
281
|
}`}
|
|
272
282
|
>
|
|
273
|
-
{aspect.label}
|
|
283
|
+
{t(`detailPage.designReference.aspects.${aspect.key}.label`)}
|
|
274
284
|
</button>
|
|
275
285
|
);
|
|
276
286
|
})}
|
|
@@ -285,7 +295,7 @@ export function DesignReferencePicker({
|
|
|
285
295
|
className="flex h-[4.75rem] items-center gap-1.5 rounded-le-lg border border-dashed border-le-ink-300 bg-le-surface px-3 text-xs text-le-ink-500 transition-colors hover:border-le-ink-400 hover:text-le-ink-700 disabled:opacity-40"
|
|
286
296
|
>
|
|
287
297
|
<ImagePlus size={14} />
|
|
288
|
-
|
|
298
|
+
{t("detailPage.designReference.add")}
|
|
289
299
|
</button>
|
|
290
300
|
) : null}
|
|
291
301
|
<input
|
|
@@ -305,7 +315,8 @@ export function DesignReferencePicker({
|
|
|
305
315
|
onChange={(e) => setInstruction(e.target.value)}
|
|
306
316
|
rows={2}
|
|
307
317
|
disabled={disabled || busy}
|
|
308
|
-
|
|
318
|
+
aria-label={t("detailPage.designReference.instruction")}
|
|
319
|
+
placeholder={t("detailPage.designReference.instructionPlaceholder")}
|
|
309
320
|
className="w-full resize-none rounded-le-lg border border-le-ink-200 bg-le-surface px-3 py-2 text-xs text-le-ink-900 outline-none placeholder:text-le-ink-400 focus:border-le-ink-400 disabled:bg-le-ink-100"
|
|
310
321
|
/>
|
|
311
322
|
<button
|
|
@@ -315,14 +326,19 @@ export function DesignReferencePicker({
|
|
|
315
326
|
className="inline-flex items-center gap-1.5 rounded-le-lg border border-le-ink-900 px-3 py-1.5 text-xs font-le-medium text-le-ink-900 transition-colors hover:bg-le-ink-900 hover:text-le-on-accent disabled:opacity-40"
|
|
316
327
|
>
|
|
317
328
|
{busy ? <Loader2 size={13} className="animate-spin" /> : null}
|
|
318
|
-
{
|
|
329
|
+
{t(
|
|
330
|
+
busy
|
|
331
|
+
? "detailPage.designReference.reading"
|
|
332
|
+
: brief
|
|
333
|
+
? "detailPage.designReference.reread"
|
|
334
|
+
: "detailPage.designReference.read",
|
|
335
|
+
)}
|
|
319
336
|
</button>
|
|
320
337
|
{/* 누르기 전에 값을 알려 준다 — 누른 뒤 402 로 알게 되면 이미 축 고르고
|
|
321
338
|
지시까지 다 적은 뒤다. 장수가 아니라 그림 크기에 붙는 값이라, 큰 그림을
|
|
322
339
|
작은 것으로 바꾸면 줄어든다. */}
|
|
323
|
-
<p className="text-[11px] text-le-ink-500">
|
|
324
|
-
|
|
325
|
-
길수록 올라가요).
|
|
340
|
+
<p data-credits={credits} className="text-[11px] text-le-ink-500">
|
|
341
|
+
{t("detailPage.designReference.creditNote", { credits, count: items.length })}
|
|
326
342
|
</p>
|
|
327
343
|
</>
|
|
328
344
|
) : null}
|
|
@@ -333,27 +349,9 @@ export function DesignReferencePicker({
|
|
|
333
349
|
);
|
|
334
350
|
}
|
|
335
351
|
|
|
336
|
-
const TONE_LABELS: Record<string, string> = {
|
|
337
|
-
casual: "캐주얼",
|
|
338
|
-
minimal: "미니멀",
|
|
339
|
-
info: "정보형",
|
|
340
|
-
"ad-like": "광고형",
|
|
341
|
-
premium: "프리미엄",
|
|
342
|
-
cute: "귀여운",
|
|
343
|
-
tech: "테크",
|
|
344
|
-
natural: "내추럴",
|
|
345
|
-
editorial: "에디토리얼",
|
|
346
|
-
};
|
|
347
|
-
|
|
348
|
-
const DENSITY_LABELS: Record<string, string> = {
|
|
349
|
-
airy: "여백형",
|
|
350
|
-
editorial: "편집형",
|
|
351
|
-
compact: "정보 밀집형",
|
|
352
|
-
cozy: "포근한",
|
|
353
|
-
};
|
|
354
|
-
|
|
355
352
|
/** 읽은 결과를 그 자리에 적는다 — 톤이 왜 바뀌었는지 보이지 않으면 제안이 아니라 사고다. */
|
|
356
353
|
function BriefSummary({ brief }: { brief: DetailPageDesignBrief }) {
|
|
354
|
+
const { t } = useTranslation("branding");
|
|
357
355
|
const colors = [brief.bg_color, ...brief.primary_colors].filter(Boolean);
|
|
358
356
|
const nothingRead =
|
|
359
357
|
!brief.tone && !brief.density && !colors.length && !brief.summary && !brief.content;
|
|
@@ -361,7 +359,7 @@ function BriefSummary({ brief }: { brief: DetailPageDesignBrief }) {
|
|
|
361
359
|
if (nothingRead) {
|
|
362
360
|
return (
|
|
363
361
|
<p className="text-[11px] text-le-ink-500">
|
|
364
|
-
|
|
362
|
+
{t("detailPage.designReference.nothingRead")}
|
|
365
363
|
</p>
|
|
366
364
|
);
|
|
367
365
|
}
|
|
@@ -371,12 +369,15 @@ function BriefSummary({ brief }: { brief: DetailPageDesignBrief }) {
|
|
|
371
369
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
372
370
|
{brief.tone ? (
|
|
373
371
|
<span className="rounded-full bg-le-ink-900 px-2 py-0.5 text-[11px] text-le-on-accent">
|
|
374
|
-
{
|
|
372
|
+
{/* 모르는 톤은 서버 값 그대로 — 새 톤이 번역보다 먼저 올 수 있다. */}
|
|
373
|
+
{t(`detailPage.designReference.tone.${brief.tone}`, { defaultValue: brief.tone })}
|
|
375
374
|
</span>
|
|
376
375
|
) : null}
|
|
377
376
|
{brief.density ? (
|
|
378
377
|
<span className="rounded-full border border-le-ink-300 px-2 py-0.5 text-[11px] text-le-ink-600">
|
|
379
|
-
{
|
|
378
|
+
{t(`detailPage.designReference.density.${brief.density}`, {
|
|
379
|
+
defaultValue: brief.density,
|
|
380
|
+
})}
|
|
380
381
|
</span>
|
|
381
382
|
) : null}
|
|
382
383
|
{colors.map((color) => (
|
|
@@ -397,7 +398,7 @@ function BriefSummary({ brief }: { brief: DetailPageDesignBrief }) {
|
|
|
397
398
|
</p>
|
|
398
399
|
) : null}
|
|
399
400
|
<p className="text-[11px] text-le-ink-400">
|
|
400
|
-
|
|
401
|
+
{t("detailPage.designReference.suggestionNote")}
|
|
401
402
|
</p>
|
|
402
403
|
</div>
|
|
403
404
|
);
|