@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.
Files changed (45) hide show
  1. package/components/detail-page/ai-generate-panel.tsx +44 -9
  2. package/components/detail-page/annotation-canvas.tsx +18 -13
  3. package/components/detail-page/chart-inspector.tsx +5 -0
  4. package/components/detail-page/confirm-delete-button.tsx +82 -0
  5. package/components/detail-page/design-reference-picker.tsx +47 -46
  6. package/components/detail-page/detail-page-download-dialog.tsx +117 -18
  7. package/components/detail-page/detail-page-editor.tsx +65 -15
  8. package/components/detail-page/detail-page-host-context.tsx +36 -2
  9. package/components/detail-page/detail-page-layers-panel.tsx +125 -21
  10. package/components/detail-page/detail-page-my-images-panel.tsx +24 -14
  11. package/components/detail-page/detail-page-my-shapes-panel.tsx +7 -6
  12. package/components/detail-page/detail-page-pages-panel.tsx +12 -9
  13. package/components/detail-page/detail-page-properties-panel.tsx +338 -1040
  14. package/components/detail-page/editor-hotkeys.tsx +57 -9
  15. package/components/detail-page/find-replace-panel.tsx +5 -2
  16. package/components/detail-page/group-prompt-edit-panel.tsx +16 -8
  17. package/components/detail-page/inspector-controls.tsx +106 -7
  18. package/components/detail-page/inspectors/gif-sections.tsx +912 -0
  19. package/components/detail-page/inspectors/shared.ts +74 -0
  20. package/components/detail-page/leviosa-canvas-workspace.tsx +95 -0
  21. package/components/detail-page/prompt-edit-panel.tsx +3 -1
  22. package/components/detail-page/table-inspector.tsx +24 -3
  23. package/components/detail-page/use-auto-save.ts +118 -47
  24. package/i18n/en.json +127 -8
  25. package/i18n/ko.json +127 -8
  26. package/index.ts +2 -0
  27. package/lib/detail-page/canvas-paste.ts +77 -0
  28. package/lib/detail-page/data-gif-payload.ts +2 -0
  29. package/lib/detail-page/design-reference.ts +2 -2
  30. package/lib/detail-page/editor-profile.ts +3 -3
  31. package/lib/detail-page/export-platforms.ts +11 -0
  32. package/lib/detail-page/group-action.ts +32 -19
  33. package/lib/detail-page/layer-move.ts +89 -113
  34. package/lib/detail-page/shape-to-image.ts +6 -4
  35. package/lib/detail-page/text-gif-layout.ts +16 -11
  36. package/lib/detail-page-canvas/export/ai.ts +29 -3
  37. package/lib/detail-page-canvas/export/document-model.ts +9 -3
  38. package/lib/detail-page-canvas/export/export-files.ts +56 -10
  39. package/lib/detail-page-canvas/export/frame.ts +83 -0
  40. package/lib/detail-page-canvas/export/psd.ts +93 -33
  41. package/lib/detail-page-canvas/export/raster.ts +35 -6
  42. package/lib/detail-page-canvas/export/svg.ts +97 -7
  43. package/lib/detail-page-canvas/export/text-layout.ts +35 -15
  44. package/package.json +2 -2
  45. 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
- setError(err instanceof Error ? err.message : t("detailPage.aiGenerate.uploadFailed"));
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
- // 생성기가 크레딧 부족을 알리면(422/402 등) 별도로 표기해 CTA를 띄운다.
419
- const maybe = err as { insufficientCredits?: boolean };
420
- if (maybe && maybe.insufficientCredits) {
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
- setError(err instanceof Error ? err.message : t("detailPage.aiGenerate.generateFailed"));
442
+ console.error("AI generation failed", err);
443
+ setError(t("detailPage.aiGenerate.generateFailed"));
425
444
  }
426
445
  } finally {
427
- setLoading(false);
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", labels?.pen ?? "그리기", <Pencil key="p" size={16} />],
671
- ["rect", labels?.rect ?? "박스", <Square key="r" size={16} />],
672
- ["arrow", labels?.arrow ?? "화살표", <ArrowUpRight key="a" size={16} />],
673
- ["text", labels?.text ?? "메모", <Type key="t" size={16} />],
674
- ["eraser", labels?.eraser ?? "지우개", <Eraser key="e" size={16} />],
675
- ["select", labels?.select ?? "이동", <MousePointer2 key="s" size={16} />],
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={labels?.note ?? "메모 입력"}
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={`${labels?.color ?? "색상"} ${c}`}
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={labels?.undo ?? "실행 취소"}
839
- aria-label={labels?.undo ?? "실행 취소"}
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={labels?.redo ?? "다시 실행"}
849
- aria-label={labels?.redo ?? "다시 실행"}
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
- setError(err instanceof Error ? err.message : "참고 사진을 붙이지 못했어요.");
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
- ? `크레딧이 모자라요. 레퍼런스 ${items.length}장을 읽으려면 ${credits}크레딧이 ` +
211
- `필요해요 (남은 크레딧 ${shortfall.remaining}).`
212
- : err instanceof Error
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">디자인 레퍼런스</p>
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
- &ldquo;이런 디자인으로&rdquo; 참고할 그림이에요. 상품 사진과 달리 페이지에
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="디자인 레퍼런스" className="h-full w-full object-cover" />
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
- {referenceOrdinal(index)}
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={`${referenceOrdinal(index)} 레퍼런스 빼기`}
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
- placeholder="1번 이미지의 색감과 폰트, 2번 이미지의 레이아웃을 참고해 주세요"
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
- {busy ? "읽는 중…" : brief ? "다시 읽기" : "레퍼런스 읽기"}
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
- 읽을 때마다 {credits}크레딧이 들어요 (레퍼런스 {items.length}장 · 그림이 크고
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
- {TONE_LABELS[brief.tone] ?? brief.tone}
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
- {DENSITY_LABELS[brief.density] ?? brief.density}
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
  );