@leviosa-ai/detail-page-editor 0.1.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 (190) hide show
  1. package/components/cardnews/color-input.tsx +380 -0
  2. package/components/detail-page/ai-generate-panel.tsx +763 -0
  3. package/components/detail-page/annotation-canvas.tsx +860 -0
  4. package/components/detail-page/annotation-dialog.tsx +397 -0
  5. package/components/detail-page/bubble-tail-overlay.tsx +209 -0
  6. package/components/detail-page/canvas-context-menu.tsx +234 -0
  7. package/components/detail-page/canvas-observer.tsx +68 -0
  8. package/components/detail-page/canvas-overlay-host.tsx +44 -0
  9. package/components/detail-page/canvas-store-facade.ts +51 -0
  10. package/components/detail-page/chart-inspector.tsx +531 -0
  11. package/components/detail-page/design-reference-picker.tsx +383 -0
  12. package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
  13. package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
  14. package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
  15. package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
  16. package/components/detail-page/detail-page-charts-panel.tsx +104 -0
  17. package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
  18. package/components/detail-page/detail-page-download-dialog.tsx +524 -0
  19. package/components/detail-page/detail-page-editor.tsx +974 -0
  20. package/components/detail-page/detail-page-elements-panel.tsx +117 -0
  21. package/components/detail-page/detail-page-font-picker.tsx +231 -0
  22. package/components/detail-page/detail-page-history-buttons.tsx +87 -0
  23. package/components/detail-page/detail-page-host-context.tsx +465 -0
  24. package/components/detail-page/detail-page-icons-panel.tsx +251 -0
  25. package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
  26. package/components/detail-page/detail-page-layers-panel.tsx +618 -0
  27. package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
  28. package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
  29. package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
  30. package/components/detail-page/detail-page-pages-panel.tsx +299 -0
  31. package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
  32. package/components/detail-page/detail-page-photos-panel.tsx +257 -0
  33. package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
  34. package/components/detail-page/detail-page-qr-panel.tsx +137 -0
  35. package/components/detail-page/detail-page-references-panel.tsx +166 -0
  36. package/components/detail-page/detail-page-selection.ts +44 -0
  37. package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
  38. package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
  39. package/components/detail-page/detail-page-tables-panel.tsx +106 -0
  40. package/components/detail-page/detail-page-text-panel.tsx +179 -0
  41. package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
  42. package/components/detail-page/edit-quota-ui.tsx +150 -0
  43. package/components/detail-page/editor-history.ts +56 -0
  44. package/components/detail-page/editor-hotkeys.tsx +165 -0
  45. package/components/detail-page/element-recents-strip.tsx +105 -0
  46. package/components/detail-page/element-rects.ts +82 -0
  47. package/components/detail-page/fill-control.tsx +261 -0
  48. package/components/detail-page/find-replace-panel.tsx +252 -0
  49. package/components/detail-page/generation-progress-fill.tsx +57 -0
  50. package/components/detail-page/gif-animator.tsx +184 -0
  51. package/components/detail-page/gif-effect-picker.tsx +136 -0
  52. package/components/detail-page/group-drill-in.tsx +219 -0
  53. package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
  54. package/components/detail-page/hover-highlight-overlay.tsx +111 -0
  55. package/components/detail-page/hovered-layer.ts +34 -0
  56. package/components/detail-page/inspector-controls.tsx +175 -0
  57. package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
  58. package/components/detail-page/nested-selection-overlay.tsx +364 -0
  59. package/components/detail-page/panel-search-input.tsx +49 -0
  60. package/components/detail-page/prompt-edit-panel.tsx +146 -0
  61. package/components/detail-page/section-height-handle.tsx +276 -0
  62. package/components/detail-page/section-reauthor-controller.tsx +210 -0
  63. package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
  64. package/components/detail-page/spec-resize-absorber.tsx +149 -0
  65. package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
  66. package/components/detail-page/table-canvas-overlay.tsx +707 -0
  67. package/components/detail-page/table-inspector.tsx +586 -0
  68. package/components/ui/button.tsx +64 -0
  69. package/components/ui/command.tsx +175 -0
  70. package/components/ui/dialog.tsx +167 -0
  71. package/components/ui/label.tsx +24 -0
  72. package/components/ui/select.tsx +190 -0
  73. package/components/ui/slider.tsx +63 -0
  74. package/components/ui/switch.tsx +35 -0
  75. package/components/ui/tooltip.tsx +57 -0
  76. package/config/detail-page-bundle-font-tags.json +39 -0
  77. package/config/detail-page-fonts.json +373 -0
  78. package/index.ts +24 -0
  79. package/lib/cardnews/font-loader.ts +306 -0
  80. package/lib/detail-page/animation-sniff.ts +101 -0
  81. package/lib/detail-page/asset-bytes-url.ts +70 -0
  82. package/lib/detail-page/basic-shapes.ts +603 -0
  83. package/lib/detail-page/canvas-menu.ts +182 -0
  84. package/lib/detail-page/chart/defaults.ts +283 -0
  85. package/lib/detail-page/chart/format.ts +66 -0
  86. package/lib/detail-page/chart/normalize.ts +149 -0
  87. package/lib/detail-page/chart/palette.ts +85 -0
  88. package/lib/detail-page/chart/paste.ts +82 -0
  89. package/lib/detail-page/chart/preview.ts +78 -0
  90. package/lib/detail-page/chart/render.ts +188 -0
  91. package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
  92. package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
  93. package/lib/detail-page/chart/renderers/donut.ts +186 -0
  94. package/lib/detail-page/chart/renderers/gauge.ts +96 -0
  95. package/lib/detail-page/chart/renderers/line.ts +143 -0
  96. package/lib/detail-page/chart/renderers/shared.ts +263 -0
  97. package/lib/detail-page/chart/renderers/stack.ts +121 -0
  98. package/lib/detail-page/chart/sync.ts +92 -0
  99. package/lib/detail-page/chart/types.ts +145 -0
  100. package/lib/detail-page/css-color.ts +34 -0
  101. package/lib/detail-page/data-gif-payload.ts +167 -0
  102. package/lib/detail-page/design-reference.ts +244 -0
  103. package/lib/detail-page/distribute.ts +89 -0
  104. package/lib/detail-page/editor-routes.ts +10 -0
  105. package/lib/detail-page/element-recents.ts +167 -0
  106. package/lib/detail-page/find-replace.ts +120 -0
  107. package/lib/detail-page/format-painter.ts +159 -0
  108. package/lib/detail-page/gif-frames.ts +216 -0
  109. package/lib/detail-page/gif-timeline.ts +65 -0
  110. package/lib/detail-page/group-action.ts +62 -0
  111. package/lib/detail-page/hangul-roman.ts +58 -0
  112. package/lib/detail-page/icon-curated.ts +290 -0
  113. package/lib/detail-page/icon-fuzzy.ts +250 -0
  114. package/lib/detail-page/icon-keywords.generated.ts +3766 -0
  115. package/lib/detail-page/icon-keywords.ko.ts +524 -0
  116. package/lib/detail-page/icon-search.ts +257 -0
  117. package/lib/detail-page/icons.ts +67 -0
  118. package/lib/detail-page/image-credit.ts +121 -0
  119. package/lib/detail-page/image-data-uri.ts +29 -0
  120. package/lib/detail-page/insert-image.ts +75 -0
  121. package/lib/detail-page/insert-shape.ts +49 -0
  122. package/lib/detail-page/korean-dictionary.ts +187 -0
  123. package/lib/detail-page/layer-move.ts +257 -0
  124. package/lib/detail-page/new-product-draft-store.ts +105 -0
  125. package/lib/detail-page/new-product-draft.ts +283 -0
  126. package/lib/detail-page/personal-images-refresh.ts +30 -0
  127. package/lib/detail-page/product-source.ts +587 -0
  128. package/lib/detail-page/qr-code.ts +214 -0
  129. package/lib/detail-page/reference-image.ts +103 -0
  130. package/lib/detail-page/reference-upload.ts +181 -0
  131. package/lib/detail-page/replace-page.ts +35 -0
  132. package/lib/detail-page/replace-with-gif.ts +251 -0
  133. package/lib/detail-page/section-height.ts +131 -0
  134. package/lib/detail-page/section-reauthor-bus.ts +61 -0
  135. package/lib/detail-page/shape-to-image.ts +219 -0
  136. package/lib/detail-page/spec-group/clipboard.ts +35 -0
  137. package/lib/detail-page/spec-group/sync.ts +377 -0
  138. package/lib/detail-page/spec-group/text-metrics.ts +48 -0
  139. package/lib/detail-page/stock-photos.ts +88 -0
  140. package/lib/detail-page/svg-colors.ts +74 -0
  141. package/lib/detail-page/table/defaults.ts +344 -0
  142. package/lib/detail-page/table/edit.ts +312 -0
  143. package/lib/detail-page/table/harvest.ts +103 -0
  144. package/lib/detail-page/table/normalize.ts +125 -0
  145. package/lib/detail-page/table/paste.ts +49 -0
  146. package/lib/detail-page/table/preview.ts +66 -0
  147. package/lib/detail-page/table/render.ts +388 -0
  148. package/lib/detail-page/table/sync.ts +107 -0
  149. package/lib/detail-page/table/types.ts +102 -0
  150. package/lib/detail-page/text-gif-layout.ts +245 -0
  151. package/lib/detail-page/text-gif-payload.ts +67 -0
  152. package/lib/detail-page/text-presets.ts +230 -0
  153. package/lib/detail-page/use-fake-progress.ts +67 -0
  154. package/lib/detail-page/z-order.ts +82 -0
  155. package/lib/detail-page-canvas/bubble-path.ts +190 -0
  156. package/lib/detail-page-canvas/canvas-key.ts +22 -0
  157. package/lib/detail-page-canvas/clip-rect.ts +65 -0
  158. package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
  159. package/lib/detail-page-canvas/editor-fonts.ts +161 -0
  160. package/lib/detail-page-canvas/export/ai.ts +413 -0
  161. package/lib/detail-page-canvas/export/color.ts +35 -0
  162. package/lib/detail-page-canvas/export/document-model.ts +78 -0
  163. package/lib/detail-page-canvas/export/export-files.ts +290 -0
  164. package/lib/detail-page-canvas/export/font-subset.ts +264 -0
  165. package/lib/detail-page-canvas/export/gif-export.ts +310 -0
  166. package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
  167. package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
  168. package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
  169. package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
  170. package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
  171. package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
  172. package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
  173. package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
  174. package/lib/detail-page-canvas/export/psd.ts +354 -0
  175. package/lib/detail-page-canvas/export/raster.ts +255 -0
  176. package/lib/detail-page-canvas/export/svg.ts +479 -0
  177. package/lib/detail-page-canvas/export/text-layout.ts +142 -0
  178. package/lib/detail-page-canvas/font-catalog.ts +177 -0
  179. package/lib/detail-page-canvas/font-tags.ts +47 -0
  180. package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
  181. package/lib/detail-page-canvas/konva-fallback.ts +477 -0
  182. package/lib/detail-page-canvas/text-background-plan.ts +124 -0
  183. package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
  184. package/lib/utils.ts +10 -0
  185. package/package.json +68 -0
  186. package/types/commerce.ts +171 -0
  187. package/types/detail-page-api.ts +200 -0
  188. package/types/detail-page-canvas.ts +20 -0
  189. package/types/fontkit.d.ts +42 -0
  190. package/types/gifenc.d.ts +51 -0
@@ -0,0 +1,383 @@
1
+ "use client";
2
+
3
+ import { useCallback, useRef, useState } from "react";
4
+ import { ImagePlus, Loader2, X } from "lucide-react";
5
+
6
+ import {
7
+ DESIGN_REFERENCE_ASPECTS,
8
+ MAX_DESIGN_REFERENCES,
9
+ REFERENCE_ACCEPT_ATTR,
10
+ estimateBriefCredits,
11
+ finalizeReferenceDataUri,
12
+ readReferenceFile,
13
+ referenceOrdinal,
14
+ type DesignReferenceAspect,
15
+ } from "../../lib/detail-page/design-reference";
16
+ import { useDetailPageHost } from "./detail-page-host-context";
17
+ import type { DetailPageDesignBrief } from "./detail-page-host-context";
18
+
19
+ /**
20
+ * 생성 설문의 디자인 레퍼런스 — "이런 디자인으로" 를 그림으로 가리킨다.
21
+ *
22
+ * 상품 소재 사진과는 **다른 축**이다. 소재 사진은 페이지에 실릴 그림이고, 여기 붙이는
23
+ * 것은 실리지 않는다 — 구조와 색을 고르는 근거로만 쓰인다. 같은 자리에 두면 셀러가
24
+ * 자기 제품 사진을 여기 올리고 "왜 안 나오지"가 된다.
25
+ *
26
+ * ## 왜 판독을 따로 누르게 하는가
27
+ *
28
+ * 판독은 **제안**이다. 결과를 보여 주고 셀러가 고칠 수 있어야 한다 — 설문 저장에 몰래
29
+ * 끼워 넣으면 톤이 왜 바뀌었는지 알 수 없는 채로 다음 단계로 넘어간다. 그래서 버튼을
30
+ * 누르는 자리를 두고, 읽은 결과를 그 자리에 적는다.
31
+ *
32
+ * 그림 자체는 위로 올려 보내지 않는다. 판독 뒤로는 쓰는 곳이 없고, 설문에 base64 를
33
+ * 심으면 그 행의 모든 조회가 그 바이트를 끌고 다닌다.
34
+ */
35
+
36
+ let seq = 0;
37
+
38
+ function nextId(): string {
39
+ seq += 1;
40
+ return `design-ref-${seq}`;
41
+ }
42
+
43
+ type Picked = {
44
+ id: string;
45
+ uri: string;
46
+ aspects: DesignReferenceAspect[];
47
+ /** 이 장의 비전 입력 토큰. 0 은 "아직 못 쟀다" — 값은 비싼 쪽으로 잡힌다. */
48
+ inputTokens: number;
49
+ };
50
+
51
+ export function DesignReferencePicker({
52
+ brief,
53
+ onBriefChange,
54
+ initialReferences,
55
+ disabled = false,
56
+ }: {
57
+ /** 지금까지 읽은 판독. 없으면 아직 안 읽은 것이다. */
58
+ brief: DetailPageDesignBrief | null;
59
+ onBriefChange: (brief: DetailPageDesignBrief | null) => void;
60
+ /**
61
+ * 앞 화면에서 이미 붙여 둔 레퍼런스. 새 상품 시작 화면이 여기로 넘겨 준다 —
62
+ * 거기서는 담기만 하고 **읽지 않는다**(판독은 크레딧을 선차감하므로 결과를 볼
63
+ * 화면에서 눌러야 한다).
64
+ *
65
+ * 첫 렌더에서만 읽는다. 뒤에 값이 바뀌어도 유저가 여기서 빼거나 더한 것을
66
+ * 되돌리지 않는다.
67
+ */
68
+ initialReferences?: readonly {
69
+ uri: string;
70
+ aspects: DesignReferenceAspect[];
71
+ /** 앞 화면에서 이미 잰 크기. 0 이면 여기서도 비싼 쪽으로 잡는다. */
72
+ inputTokens: number;
73
+ }[];
74
+ disabled?: boolean;
75
+ }) {
76
+ const { api } = useDetailPageHost();
77
+ const [items, setItems] = useState<Picked[]>(() =>
78
+ (initialReferences ?? [])
79
+ .slice(0, MAX_DESIGN_REFERENCES)
80
+ .map((entry) => ({
81
+ id: nextId(),
82
+ uri: entry.uri,
83
+ aspects: [...entry.aspects],
84
+ inputTokens: entry.inputTokens,
85
+ })),
86
+ );
87
+ const [instruction, setInstruction] = useState("");
88
+ const [busy, setBusy] = useState(false);
89
+ const [error, setError] = useState<string | null>(null);
90
+ const fileRef = useRef<HTMLInputElement>(null);
91
+
92
+ const attach = useCallback(
93
+ async (files: FileList | null) => {
94
+ if (!files?.length) return;
95
+ setError(null);
96
+ const room = MAX_DESIGN_REFERENCES - items.length;
97
+ if (room <= 0) return;
98
+ const picked = Array.from(files).slice(0, room);
99
+ if (fileRef.current) fileRef.current.value = "";
100
+
101
+ const added: Picked[] = [];
102
+ for (const file of picked) {
103
+ try {
104
+ added.push({
105
+ id: nextId(),
106
+ uri: await readReferenceFile(file),
107
+ aspects: [],
108
+ inputTokens: 0,
109
+ });
110
+ } catch (err) {
111
+ setError(err instanceof Error ? err.message : "참고 사진을 붙이지 못했어요.");
112
+ }
113
+ }
114
+ if (!added.length) return;
115
+ // 원본으로 먼저 띄운다 — 줄이기를 기다리는 동안 아무것도 안 뜨면 첨부가 먹히지
116
+ // 않은 것처럼 보인다.
117
+ setItems((prev) => [...prev, ...added].slice(0, MAX_DESIGN_REFERENCES));
118
+
119
+ for (const item of added) {
120
+ void finalizeReferenceDataUri(item.uri).then((result) => {
121
+ if ("error" in result) {
122
+ setItems((prev) => prev.filter((entry) => entry.id !== item.id));
123
+ setError(result.error);
124
+ return;
125
+ }
126
+ // 줄일 것이 없었어도 크기는 받아 적는다 — 값이 그 크기로 정해지므로 여기서
127
+ // 건너뛰면 안 줄인 그림만 "모르는 크기"로 비싸게 잡힌다.
128
+ setItems((prev) =>
129
+ prev.map((entry) =>
130
+ entry.id === item.id
131
+ ? { ...entry, uri: result.uri, inputTokens: result.inputTokens }
132
+ : entry,
133
+ ),
134
+ );
135
+ });
136
+ }
137
+ },
138
+ [items.length],
139
+ );
140
+
141
+ const toggleAspect = useCallback((id: string, aspect: DesignReferenceAspect) => {
142
+ setItems((prev) =>
143
+ prev.map((entry) =>
144
+ entry.id === id
145
+ ? {
146
+ ...entry,
147
+ aspects: entry.aspects.includes(aspect)
148
+ ? entry.aspects.filter((a) => a !== aspect)
149
+ : [...entry.aspects, aspect],
150
+ }
151
+ : entry,
152
+ ),
153
+ );
154
+ }, []);
155
+
156
+ const remove = useCallback(
157
+ (id: string) => {
158
+ setItems((prev) => {
159
+ const next = prev.filter((entry) => entry.id !== id);
160
+ // 판독의 근거가 사라졌으면 판독도 내린다 — 없는 그림에서 읽은 톤이 남아 있으면
161
+ // 셀러는 그것이 어디서 왔는지 알 수 없다.
162
+ if (!next.length) onBriefChange(null);
163
+ return next;
164
+ });
165
+ },
166
+ [onBriefChange],
167
+ );
168
+
169
+ // 값은 장수가 아니라 **붙인 그림의 크기**로 정해진다. 서버와 같은 공식이라 여기 뜬 수가
170
+ // 곧 청구될 수다(선차감이라 미리 맞아야 한다).
171
+ const credits = estimateBriefCredits(items.map((item) => item.inputTokens));
172
+
173
+ const analyze = useCallback(async () => {
174
+ if (!items.length || busy) return;
175
+ setBusy(true);
176
+ setError(null);
177
+ try {
178
+ const result = await api.analyzeDetailPageDesignReferences({
179
+ references: items.map((item) => ({ url: item.uri, aspects: item.aspects })),
180
+ instruction: instruction.trim() || undefined,
181
+ });
182
+ onBriefChange(result.brief);
183
+ } catch (err) {
184
+ // 잔액 부족은 "읽지 못했어요"와 원인이 다르다 — 셀러가 할 일(충전)이 정해져
185
+ // 있으므로 그 말을 그대로 해 준다. 다시 눌러도 결과가 달라지지 않는다.
186
+ const shortfall = api.asInsufficientCreditsError(err);
187
+ setError(
188
+ shortfall
189
+ ? `크레딧이 모자라요. 레퍼런스 ${items.length}장을 읽으려면 ${credits}크레딧이 ` +
190
+ `필요해요 (남은 크레딧 ${shortfall.remaining}).`
191
+ : err instanceof Error
192
+ ? err.message
193
+ : "레퍼런스를 읽지 못했어요.",
194
+ );
195
+ } finally {
196
+ setBusy(false);
197
+ }
198
+ }, [api, busy, credits, instruction, items, onBriefChange]);
199
+
200
+ return (
201
+ <div className="space-y-3 rounded-lg bg-neutral-50 p-3">
202
+ <div>
203
+ <p className="text-xs font-medium text-neutral-700">디자인 레퍼런스</p>
204
+ <p className="mt-1 text-[11px] text-neutral-500">
205
+ &ldquo;이런 디자인으로&rdquo; 참고할 그림이에요. 상품 사진과 달리 페이지에
206
+ 들어가지 않고, 구조와 색을 고르는 근거로만 써요. 최대{" "}
207
+ {MAX_DESIGN_REFERENCES}장.
208
+ </p>
209
+ </div>
210
+
211
+ <div className="flex flex-wrap items-start gap-2">
212
+ {items.map((item, index) => (
213
+ <div
214
+ key={item.id}
215
+ className="flex items-start gap-2 rounded-lg border border-neutral-200 bg-white p-1.5"
216
+ >
217
+ <div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-md bg-neutral-100">
218
+ {/* 붙인 사진은 data URI라 next/image 로 최적화할 것이 없다. */}
219
+ {/* eslint-disable-next-line @next/next/no-img-element */}
220
+ <img src={item.uri} alt="디자인 레퍼런스" className="h-full w-full object-cover" />
221
+ <span className="absolute left-0.5 top-0.5 rounded bg-neutral-900/75 px-1 text-[10px] font-semibold leading-4 text-white">
222
+ {referenceOrdinal(index)}
223
+ </span>
224
+ <button
225
+ type="button"
226
+ disabled={disabled || busy}
227
+ onClick={() => remove(item.id)}
228
+ aria-label={`${referenceOrdinal(index)} 레퍼런스 빼기`}
229
+ className="absolute right-0.5 top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-neutral-900/70 text-white disabled:opacity-40"
230
+ >
231
+ <X size={10} />
232
+ </button>
233
+ </div>
234
+ <div className="flex max-w-[13rem] flex-wrap gap-1">
235
+ {DESIGN_REFERENCE_ASPECTS.map((aspect) => {
236
+ const on = item.aspects.includes(aspect.key);
237
+ return (
238
+ <button
239
+ key={aspect.key}
240
+ type="button"
241
+ disabled={disabled || busy}
242
+ aria-pressed={on}
243
+ // 축 이름만으로는 "내용 구성"이 문구까지인지 알 수 없다.
244
+ title={aspect.hint}
245
+ onClick={() => toggleAspect(item.id, aspect.key)}
246
+ className={`rounded-full border px-2 py-0.5 text-[11px] transition-colors disabled:opacity-40 ${
247
+ on
248
+ ? "border-neutral-900 bg-neutral-900 text-white"
249
+ : "border-neutral-200 text-neutral-500 hover:border-neutral-400 hover:text-neutral-700"
250
+ }`}
251
+ >
252
+ {aspect.label}
253
+ </button>
254
+ );
255
+ })}
256
+ </div>
257
+ </div>
258
+ ))}
259
+ {items.length < MAX_DESIGN_REFERENCES ? (
260
+ <button
261
+ type="button"
262
+ disabled={disabled || busy}
263
+ onClick={() => fileRef.current?.click()}
264
+ className="flex h-[4.75rem] items-center gap-1.5 rounded-lg border border-dashed border-neutral-300 bg-white px-3 text-xs text-neutral-500 transition-colors hover:border-neutral-400 hover:text-neutral-700 disabled:opacity-40"
265
+ >
266
+ <ImagePlus size={14} />
267
+ 레퍼런스 추가
268
+ </button>
269
+ ) : null}
270
+ <input
271
+ ref={fileRef}
272
+ type="file"
273
+ accept={REFERENCE_ACCEPT_ATTR}
274
+ multiple
275
+ hidden
276
+ onChange={(e) => void attach(e.target.files)}
277
+ />
278
+ </div>
279
+
280
+ {items.length ? (
281
+ <>
282
+ <textarea
283
+ value={instruction}
284
+ onChange={(e) => setInstruction(e.target.value)}
285
+ rows={2}
286
+ disabled={disabled || busy}
287
+ placeholder="1번 이미지의 색감과 폰트, 2번 이미지의 레이아웃을 참고해 주세요"
288
+ className="w-full resize-none rounded-lg border border-neutral-200 bg-white px-3 py-2 text-xs text-neutral-900 outline-none placeholder:text-neutral-400 focus:border-neutral-400 disabled:bg-neutral-100"
289
+ />
290
+ <button
291
+ type="button"
292
+ disabled={disabled || busy}
293
+ onClick={() => void analyze()}
294
+ className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-900 px-3 py-1.5 text-xs font-medium text-neutral-900 transition-colors hover:bg-neutral-900 hover:text-white disabled:opacity-40"
295
+ >
296
+ {busy ? <Loader2 size={13} className="animate-spin" /> : null}
297
+ {busy ? "읽는 중…" : brief ? "다시 읽기" : "레퍼런스 읽기"}
298
+ </button>
299
+ {/* 누르기 전에 값을 알려 준다 — 누른 뒤 402 로 알게 되면 이미 축 고르고
300
+ 지시까지 다 적은 뒤다. 장수가 아니라 그림 크기에 붙는 값이라, 큰 그림을
301
+ 작은 것으로 바꾸면 줄어든다. */}
302
+ <p className="text-[11px] text-neutral-500">
303
+ 읽을 때마다 {credits}크레딧이 들어요 (레퍼런스 {items.length}장 · 그림이 크고
304
+ 길수록 올라가요).
305
+ </p>
306
+ </>
307
+ ) : null}
308
+
309
+ {brief ? <BriefSummary brief={brief} /> : null}
310
+ {error ? <p className="text-[11px] text-red-600">{error}</p> : null}
311
+ </div>
312
+ );
313
+ }
314
+
315
+ const TONE_LABELS: Record<string, string> = {
316
+ casual: "캐주얼",
317
+ minimal: "미니멀",
318
+ info: "정보형",
319
+ "ad-like": "광고형",
320
+ premium: "프리미엄",
321
+ cute: "귀여운",
322
+ tech: "테크",
323
+ natural: "내추럴",
324
+ editorial: "에디토리얼",
325
+ };
326
+
327
+ const DENSITY_LABELS: Record<string, string> = {
328
+ airy: "여백형",
329
+ editorial: "편집형",
330
+ compact: "정보 밀집형",
331
+ cozy: "포근한",
332
+ };
333
+
334
+ /** 읽은 결과를 그 자리에 적는다 — 톤이 왜 바뀌었는지 보이지 않으면 제안이 아니라 사고다. */
335
+ function BriefSummary({ brief }: { brief: DetailPageDesignBrief }) {
336
+ const colors = [brief.bg_color, ...brief.primary_colors].filter(Boolean);
337
+ const nothingRead =
338
+ !brief.tone && !brief.density && !colors.length && !brief.summary && !brief.content;
339
+
340
+ if (nothingRead) {
341
+ return (
342
+ <p className="text-[11px] text-neutral-500">
343
+ 레퍼런스에서 읽어낸 것이 없어요. 판면이 잘 보이는 그림으로 바꿔 보세요.
344
+ </p>
345
+ );
346
+ }
347
+
348
+ return (
349
+ <div className="space-y-1.5 rounded-lg border border-neutral-200 bg-white p-2.5">
350
+ <div className="flex flex-wrap items-center gap-1.5">
351
+ {brief.tone ? (
352
+ <span className="rounded-full bg-neutral-900 px-2 py-0.5 text-[11px] text-white">
353
+ {TONE_LABELS[brief.tone] ?? brief.tone}
354
+ </span>
355
+ ) : null}
356
+ {brief.density ? (
357
+ <span className="rounded-full border border-neutral-300 px-2 py-0.5 text-[11px] text-neutral-600">
358
+ {DENSITY_LABELS[brief.density] ?? brief.density}
359
+ </span>
360
+ ) : null}
361
+ {colors.map((color) => (
362
+ <span
363
+ key={color}
364
+ title={color}
365
+ style={{ backgroundColor: color }}
366
+ className="h-4 w-4 rounded-full border border-neutral-300"
367
+ />
368
+ ))}
369
+ </div>
370
+ {brief.summary ? (
371
+ <p className="text-[11px] text-neutral-600">{brief.summary}</p>
372
+ ) : null}
373
+ {brief.typography || brief.layout || brief.content ? (
374
+ <p className="text-[11px] text-neutral-500">
375
+ {[brief.typography, brief.layout, brief.content].filter(Boolean).join(" · ")}
376
+ </p>
377
+ ) : null}
378
+ <p className="text-[11px] text-neutral-400">
379
+ 읽은 결과는 제안이에요. 위 디자인 톤을 직접 바꾸면 그 선택이 우선해요.
380
+ </p>
381
+ </div>
382
+ );
383
+ }
@@ -0,0 +1,180 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { ImageOff } from "lucide-react";
5
+ import { useTranslation } from "react-i18next";
6
+
7
+ import { PanelSearchInput } from "./panel-search-input";
8
+ import { insertShape } from "../../lib/detail-page/insert-shape";
9
+ import {
10
+ BASIC_SHAPES,
11
+ NATIVE_SHAPE_IDS,
12
+ SHAPE_CATEGORIES,
13
+ SHAPE_FILL,
14
+ shapeMarkup,
15
+ shapeMatches,
16
+ type BasicShape,
17
+ } from "../../lib/detail-page/basic-shapes";
18
+
19
+ /**
20
+ * 기본 도형 — 스톡 `ElementsSection`의 도형/선 그리드를 대신한다.
21
+ *
22
+ * 네모와 동그라미는 **네이티브 `figure`**로 넣는다(색·모서리·그림자를 우측 패널이 바로
23
+ * 만진다). 나머지는 `svg`로 넣는다 — 우리 렌더러가 아는 `subType`은 네모와 타원 둘뿐이고,
24
+ * 나머지를 subType으로 늘리면 **문서 포맷이 갈라진다**(하드룰 2: Canvas JSON이 계약).
25
+ * svg는 양쪽 렌더러가 모두 그대로 그린다.
26
+ *
27
+ * 카탈로그와 검색 사전은 `@/lib/detail-page/basic-shapes`에 있다 — 캔버스를 모르는
28
+ * 순수 목록이라 렌더 없이 테스트할 수 있다.
29
+ *
30
+ * 검색은 아이콘과 달리 **서버를 안 탄다.** 목록이 70개 고정이라 브라우저에서 거르는
31
+ * 편이 빠르고, 오프라인에서도 산다.
32
+ */
33
+
34
+ type PageLike = {
35
+ computedWidth: number;
36
+ computedHeight: number;
37
+ addElement: (opts: Record<string, unknown>) => unknown;
38
+ };
39
+ type StoreLike = { activePage?: PageLike; pages: PageLike[] };
40
+
41
+ export function insertFigure(
42
+ store: unknown,
43
+ patch: Record<string, unknown>,
44
+ ): void {
45
+ const s = store as StoreLike;
46
+ const page = s.activePage ?? s.pages[0];
47
+ if (!page) return;
48
+ const size = Math.round(Math.min(page.computedWidth * 0.28, 260));
49
+ page.addElement({
50
+ type: "figure",
51
+ subType: "rect",
52
+ fill: SHAPE_FILL,
53
+ width: size,
54
+ height: size,
55
+ x: Math.round((page.computedWidth - size) / 2),
56
+ y: Math.round((page.computedHeight - size) / 2),
57
+ ...patch,
58
+ });
59
+ }
60
+
61
+ const FIGURE_PATCH: Record<string, Record<string, unknown>> = {
62
+ rect: {},
63
+ rounded: { cornerRadius: 24 },
64
+ circle: { subType: "circle" },
65
+ };
66
+
67
+ const CELL =
68
+ "flex aspect-square items-center justify-center rounded-lg border border-neutral-200 p-2 hover:border-neutral-400";
69
+
70
+ function FigureCell({ id, store }: { id: string; store: unknown }) {
71
+ const { t } = useTranslation("branding");
72
+ return (
73
+ <button
74
+ type="button"
75
+ onClick={() => insertFigure(store, FIGURE_PATCH[id] ?? {})}
76
+ title={t("detailPage.shapes.insertHint")}
77
+ aria-label={t(`detailPage.shapes.basic.${id}`)}
78
+ className={CELL}
79
+ >
80
+ <span
81
+ aria-hidden="true"
82
+ className="block h-7 w-7 bg-neutral-300"
83
+ style={{
84
+ borderRadius: id === "circle" ? "9999px" : id === "rounded" ? 8 : 0,
85
+ }}
86
+ />
87
+ </button>
88
+ );
89
+ }
90
+
91
+ function ShapeCell({ shape, store }: { shape: BasicShape; store: unknown }) {
92
+ const { t } = useTranslation("branding");
93
+ const markup = shapeMarkup(shape);
94
+ return (
95
+ <button
96
+ type="button"
97
+ onClick={() => insertShape(store, markup, shape.viewBox)}
98
+ title={t("detailPage.shapes.insertHint")}
99
+ aria-label={t(`detailPage.shapes.basic.${shape.id}`)}
100
+ className={CELL}
101
+ >
102
+ <span
103
+ aria-hidden="true"
104
+ className="block h-7 w-7 [&>svg]:h-full [&>svg]:w-full"
105
+ dangerouslySetInnerHTML={{ __html: markup }}
106
+ />
107
+ </button>
108
+ );
109
+ }
110
+
111
+ export function DetailPageBasicShapes({ store }: { store: unknown }) {
112
+ const { t } = useTranslation("branding");
113
+ const [query, setQuery] = useState("");
114
+
115
+ const found = useMemo(() => {
116
+ const text = query.trim();
117
+ if (!text) return null;
118
+ return {
119
+ figures: NATIVE_SHAPE_IDS.filter((id) => shapeMatches(id, text)),
120
+ shapes: BASIC_SHAPES.filter((shape) => shapeMatches(shape.id, text)),
121
+ };
122
+ }, [query]);
123
+
124
+ return (
125
+ <div className="flex h-full flex-col">
126
+ <div className="shrink-0 px-3 pt-3">
127
+ <PanelSearchInput
128
+ value={query}
129
+ onChange={setQuery}
130
+ placeholder={t("detailPage.shapes.searchPlaceholder")}
131
+ label={t("detailPage.shapes.searchLabel")}
132
+ />
133
+ </div>
134
+
135
+ <div className="min-h-0 flex-1 overflow-y-auto p-3">
136
+ {found ? (
137
+ found.figures.length + found.shapes.length === 0 ? (
138
+ <div className="flex flex-col items-center gap-2 py-6 text-center text-neutral-400">
139
+ <ImageOff aria-hidden="true" size={20} />
140
+ <p className="text-xs">{t("detailPage.shapes.searchEmpty")}</p>
141
+ </div>
142
+ ) : (
143
+ // 검색 중에는 갈래 제목을 안 세운다 — 두세 개짜리 묶음이 일곱 개면
144
+ // 결과보다 제목이 더 많아진다.
145
+ <div className="grid grid-cols-4 gap-2">
146
+ {found.figures.map((id) => (
147
+ <FigureCell key={id} id={id} store={store} />
148
+ ))}
149
+ {found.shapes.map((shape) => (
150
+ <ShapeCell key={shape.id} shape={shape} store={store} />
151
+ ))}
152
+ </div>
153
+ )
154
+ ) : (
155
+ SHAPE_CATEGORIES.map((category) => {
156
+ const shapes = BASIC_SHAPES.filter((shape) => shape.category === category);
157
+ if (!shapes.length) return null;
158
+ return (
159
+ <div key={category} className="mb-4 last:mb-0">
160
+ <p className="mb-2 text-xs font-medium text-neutral-500">
161
+ {t(`detailPage.shapes.categories.${category}`)}
162
+ </p>
163
+ <div className="grid grid-cols-4 gap-2">
164
+ {category === "basic"
165
+ ? NATIVE_SHAPE_IDS.map((id) => (
166
+ <FigureCell key={id} id={id} store={store} />
167
+ ))
168
+ : null}
169
+ {shapes.map((shape) => (
170
+ <ShapeCell key={shape.id} shape={shape} store={store} />
171
+ ))}
172
+ </div>
173
+ </div>
174
+ );
175
+ })
176
+ )}
177
+ </div>
178
+ </div>
179
+ );
180
+ }