@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,283 @@
1
+ /**
2
+ * 새 상품 브리프 초안 — 시작 화면(`/branding/detail-page-generator/new`)과 생성 화면
3
+ * 사이의 **손바꿈**.
4
+ *
5
+ * ## 왜 쿼리스트링을 그만두는가
6
+ *
7
+ * 예전에는 다이얼로그가 `?new=1&name=…&kind=…&memo=…` 로 넘겼다. 글자 세 개일 때는
8
+ * 그것으로 됐다. 사진이 들어오는 순간 끝난다 — 주소창에 그림을 태울 수는 없다.
9
+ *
10
+ * ## 왜 세 겹인가
11
+ *
12
+ * 1. **메모리** — 같은 탭에서 바로 이어지는 이동의 빠른 길이다. 저장소를 안 건드린다.
13
+ * 2. **IndexedDB** — 미리보기와 디자인 레퍼런스(둘 다 data URI)가 여기 산다. 한도가
14
+ * 사실상 없어서 4MB 짜리 레퍼런스 여섯 장도 받는다. **새로고침을 견디는 것이
15
+ * 이 겹이다.**
16
+ * 3. **`sessionStorage`** — 글자와 S3 키만. IndexedDB 가 막힌 브라우저(잠긴 WebView,
17
+ * 일부 비공개 모드)에서 마지막으로 남는 몫이다. 그림을 안 실으므로 한도를 넘길
18
+ * 일이 없다.
19
+ *
20
+ * 아래로 갈수록 잃는 것이 많고, 위가 실패해야 아래로 떨어진다.
21
+ *
22
+ * ## 왜 읽기가 비동기인가
23
+ *
24
+ * IndexedDB 가 비동기라서다. 대신 한 번 읽으면 메모리에 얹어 두고, 같은 초안을 다시
25
+ * 물으면 저장소까지 가지 않는다 — 화면이 리렌더될 때마다 디스크를 두드리면 안 된다.
26
+ *
27
+ * ## 왜 초안이 하나인가
28
+ *
29
+ * 초안은 "지금 만들던 것" 하나뿐이다. 여러 개를 쌓아 두면 어느 것이 살아 있는지
30
+ * 화면이 알 수 없고, 지우는 시점도 정할 수 없다. 새로 저장하면 앞의 것을 덮는다.
31
+ * 대신 오래된 것은 :data:`MAX_DRAFT_AGE_MS` 를 넘기면 스스로 없는 셈이 된다 —
32
+ * 지난달에 만들다 만 초안이 오늘 불쑥 돌아오면 그것대로 사고다.
33
+ */
34
+
35
+ import type { DesignReferenceAspect } from "./design-reference";
36
+ import {
37
+ deleteStoredDraft,
38
+ readStoredDraft,
39
+ writeStoredDraft,
40
+ } from "./new-product-draft-store";
41
+
42
+ const STORAGE_KEY = "leviosa.detail-page.new-product-draft.v1";
43
+
44
+ /** 이만큼 지난 초안은 없는 것으로 친다. */
45
+ export const MAX_DRAFT_AGE_MS = 7 * 24 * 60 * 60 * 1000;
46
+
47
+ /** 이미 브랜드 버킷에 올라간 사진 한 장. */
48
+ export interface NewProductDraftImage {
49
+ name: string;
50
+ s3Key: string;
51
+ contentType: string;
52
+ size: number;
53
+ /**
54
+ * 브라우저가 만든 미리보기(data URI). IndexedDB 에 실리므로 새로고침을 견딘다.
55
+ * IndexedDB 가 막힌 자리에서만 비어 있다 — 그림이 안 보일 뿐, 사진은 이미 S3 에
56
+ * 올라가 있어 생성에는 영향이 없다.
57
+ */
58
+ previewUrl?: string;
59
+ }
60
+
61
+ /** 아직 판독하지 않은 디자인 레퍼런스 한 장. */
62
+ export interface NewProductDraftReference {
63
+ uri: string;
64
+ aspects: DesignReferenceAspect[];
65
+ /**
66
+ * 줄이면서 함께 잰 비전 입력 토큰. 판독 값이 장수가 아니라 이 크기로 정해지므로
67
+ * 같이 넘긴다 — 안 넘기면 넘어온 그림만 "크기 모름"으로 떨어져 비싼 쪽으로 잡힌다.
68
+ * 0 은 못 쟀다는 뜻이다.
69
+ */
70
+ inputTokens: number;
71
+ }
72
+
73
+ export interface NewProductDraft {
74
+ id: string;
75
+ name: string;
76
+ kind: string;
77
+ description: string;
78
+ appeal: string;
79
+ spec: string;
80
+ targetGender: string;
81
+ targetAge: string;
82
+ designTone: string;
83
+ /** 페이지에 실릴 상품 사진. 시작 화면이 **최소 한 장**을 요구한다. */
84
+ productImages: NewProductDraftImage[];
85
+ /** 모델·연출 사진. 없어도 된다. */
86
+ modelImages: NewProductDraftImage[];
87
+ /** 페이지에 실리지 않는 참고 그림. 판독은 생성 화면에서 누른다. */
88
+ references: NewProductDraftReference[];
89
+ /** 저장한 시각. 오래된 초안을 걸러내는 데만 쓴다. */
90
+ savedAt: number;
91
+ }
92
+
93
+ export type NewProductDraftInput = Omit<NewProductDraft, "id" | "savedAt">;
94
+
95
+ /** 저장소까지 가지 않고 답하는 몫. 마지막으로 저장했거나 읽어 온 초안 하나다. */
96
+ let cached: NewProductDraft | null = null;
97
+ /** 같은 초안을 동시에 물었을 때 저장소를 두 번 두드리지 않게 붙잡아 두는 약속. */
98
+ let inflight: { id: string; promise: Promise<NewProductDraft | null> } | null = null;
99
+ let seq = 0;
100
+
101
+ function nextId(): string {
102
+ seq += 1;
103
+ return `npd-${Date.now().toString(36)}-${seq.toString(36)}`;
104
+ }
105
+
106
+ function session(): Storage | null {
107
+ try {
108
+ return typeof window === "undefined" ? null : window.sessionStorage;
109
+ } catch {
110
+ // 사파리 비공개 모드처럼 접근 자체가 던지는 자리가 있다.
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /** `sessionStorage` 에 실을 가벼운 몫 — 그림 바이트를 뺀 나머지. */
116
+ function lightweight(draft: NewProductDraft): NewProductDraft {
117
+ const strip = (images: NewProductDraftImage[]): NewProductDraftImage[] =>
118
+ images.map(({ previewUrl: _preview, ...rest }) => rest);
119
+ return {
120
+ ...draft,
121
+ productImages: strip(draft.productImages),
122
+ modelImages: strip(draft.modelImages),
123
+ references: [],
124
+ };
125
+ }
126
+
127
+ function text(value: unknown): string {
128
+ return typeof value === "string" ? value : "";
129
+ }
130
+
131
+ function fresh(savedAt: unknown): boolean {
132
+ // 시각을 못 읽으면 오래된 것으로 치지 않는다 — 예전 초안에는 이 값이 없다.
133
+ if (typeof savedAt !== "number" || !Number.isFinite(savedAt)) return true;
134
+ return Date.now() - savedAt <= MAX_DRAFT_AGE_MS;
135
+ }
136
+
137
+ function images(value: unknown, withPreview: boolean): NewProductDraftImage[] {
138
+ if (!Array.isArray(value)) return [];
139
+ return value.flatMap((entry) => {
140
+ if (!entry || typeof entry !== "object") return [];
141
+ const item = entry as Record<string, unknown>;
142
+ const s3Key = text(item.s3Key);
143
+ if (!s3Key) return [];
144
+ const preview = withPreview ? text(item.previewUrl) : "";
145
+ return [
146
+ {
147
+ name: text(item.name),
148
+ s3Key,
149
+ contentType: text(item.contentType) || "image/jpeg",
150
+ size: typeof item.size === "number" ? item.size : 0,
151
+ ...(preview ? { previewUrl: preview } : {}),
152
+ },
153
+ ];
154
+ });
155
+ }
156
+
157
+ function references(value: unknown): NewProductDraftReference[] {
158
+ if (!Array.isArray(value)) return [];
159
+ return value.flatMap((entry) => {
160
+ if (!entry || typeof entry !== "object") return [];
161
+ const item = entry as Record<string, unknown>;
162
+ const uri = text(item.uri);
163
+ if (!uri) return [];
164
+ const aspects = Array.isArray(item.aspects)
165
+ ? (item.aspects.filter((one) => typeof one === "string") as DesignReferenceAspect[])
166
+ : [];
167
+ return [
168
+ {
169
+ uri,
170
+ aspects,
171
+ inputTokens: typeof item.inputTokens === "number" ? item.inputTokens : 0,
172
+ },
173
+ ];
174
+ });
175
+ }
176
+
177
+ function hydrate(
178
+ record: Record<string, unknown>,
179
+ id: string,
180
+ withImages: boolean,
181
+ ): NewProductDraft {
182
+ return {
183
+ id,
184
+ name: text(record.name),
185
+ kind: text(record.kind),
186
+ description: text(record.description),
187
+ appeal: text(record.appeal),
188
+ spec: text(record.spec),
189
+ targetGender: text(record.targetGender) || "N",
190
+ targetAge: text(record.targetAge) || "30",
191
+ designTone: text(record.designTone) || "info",
192
+ productImages: images(record.productImages, withImages),
193
+ modelImages: images(record.modelImages, withImages),
194
+ references: withImages ? references(record.references) : [],
195
+ savedAt: typeof record.savedAt === "number" ? record.savedAt : 0,
196
+ };
197
+ }
198
+
199
+ /**
200
+ * 저장하고 초안 id 를 돌려준다. 이 id 를 `?new=1&draft=<id>` 로 넘긴다.
201
+ *
202
+ * 저장소 쓰기가 실패해도 던지지 않는다 — 메모리 사본이 이미 완전하므로 바로 이어지는
203
+ * 이동은 멀쩡히 산다. 잃는 것은 새로고침 복구뿐이다.
204
+ */
205
+ export async function saveNewProductDraft(input: NewProductDraftInput): Promise<string> {
206
+ const draft: NewProductDraft = { ...input, id: nextId(), savedAt: Date.now() };
207
+ cached = draft;
208
+ inflight = null;
209
+ try {
210
+ session()?.setItem(STORAGE_KEY, JSON.stringify(lightweight(draft)));
211
+ } catch {
212
+ // 한도를 넘겼거나 저장소가 막혔다. 아래 IndexedDB 와 메모리로 계속 간다.
213
+ }
214
+ await writeStoredDraft(draft);
215
+ return draft.id;
216
+ }
217
+
218
+ /**
219
+ * id 가 맞는 초안을 돌려준다.
220
+ *
221
+ * 메모리 → IndexedDB → `sessionStorage` 순으로 찾는다. 앞에서 찾을수록 완전하다.
222
+ * IndexedDB 에서 찾아 왔으면 메모리에 얹어 두고 다음부터는 거기서 답한다.
223
+ */
224
+ export async function readNewProductDraft(id: string): Promise<NewProductDraft | null> {
225
+ if (!id) return null;
226
+ if (cached?.id === id) return fresh(cached.savedAt) ? cached : null;
227
+ if (inflight?.id === id) return inflight.promise;
228
+
229
+ const promise = loadDraft(id);
230
+ inflight = { id, promise };
231
+ try {
232
+ return await promise;
233
+ } finally {
234
+ if (inflight?.promise === promise) inflight = null;
235
+ }
236
+ }
237
+
238
+ async function loadDraft(id: string): Promise<NewProductDraft | null> {
239
+ const stored = await readStoredDraft();
240
+ if (stored && typeof stored === "object") {
241
+ const record = stored as Record<string, unknown>;
242
+ if (text(record.id) === id && fresh(record.savedAt)) {
243
+ const draft = hydrate(record, id, true);
244
+ cached = draft;
245
+ return draft;
246
+ }
247
+ }
248
+
249
+ const raw = (() => {
250
+ try {
251
+ return session()?.getItem(STORAGE_KEY) ?? null;
252
+ } catch {
253
+ return null;
254
+ }
255
+ })();
256
+ if (!raw) return null;
257
+
258
+ let parsed: unknown;
259
+ try {
260
+ parsed = JSON.parse(raw);
261
+ } catch {
262
+ return null;
263
+ }
264
+ if (!parsed || typeof parsed !== "object") return null;
265
+ const record = parsed as Record<string, unknown>;
266
+ if (text(record.id) !== id || !fresh(record.savedAt)) return null;
267
+
268
+ // 그림 없는 몫이다. 메모리에 얹지 않는다 — 나중에 IndexedDB 가 풀리면 완전한 사본을
269
+ // 다시 받을 수 있어야 한다.
270
+ return hydrate(record, id, false);
271
+ }
272
+
273
+ /** 초안을 버린다. 로그아웃처럼 이 브라우저에서 흔적을 지울 때 부른다. */
274
+ export async function clearNewProductDraft(): Promise<void> {
275
+ cached = null;
276
+ inflight = null;
277
+ try {
278
+ session()?.removeItem(STORAGE_KEY);
279
+ } catch {
280
+ // 지우지 못해도 다음 저장이 덮는다.
281
+ }
282
+ await deleteStoredDraft();
283
+ }
@@ -0,0 +1,30 @@
1
+ // 개인 이미지·GIF 갤러리('내 이미지') 자동 갱신용 초경량 pub/sub.
2
+ //
3
+ // 생성/업로드가 성공하면 notifyPersonalImagesChanged()를 부르고, 마운트된 '내 이미지'
4
+ // 패널이 목록을 다시 불러온다. AI 생성 패널과 '내 이미지' 패널은 Canvas 사이드패널의
5
+ // 서로 다른 섹션이라 독립적으로 마운트돼 React context/props로 잇기 어렵다 → 모듈 레벨
6
+ // 이벤트로 느슨하게 연결한다.
7
+
8
+ type Listener = () => void;
9
+
10
+ const listeners = new Set<Listener>();
11
+
12
+ /** 갤러리 갱신 신호를 구독한다. 반환된 함수로 구독 해제(패널 언마운트 시 호출). */
13
+ export function onPersonalImagesChanged(listener: Listener): () => void {
14
+ listeners.add(listener);
15
+ return () => {
16
+ listeners.delete(listener);
17
+ };
18
+ }
19
+
20
+ /** 개인 이미지·GIF가 새로 생기면(생성/업로드 성공) 호출 → 구독 중인 패널이 리로드. */
21
+ export function notifyPersonalImagesChanged(): void {
22
+ // 리스너가 자기 자신을 해제할 수 있으므로 스냅샷을 순회한다.
23
+ for (const listener of [...listeners]) {
24
+ try {
25
+ listener();
26
+ } catch {
27
+ // 개별 리스너 오류는 다른 리스너에 영향 주지 않도록 무시.
28
+ }
29
+ }
30
+ }