@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,299 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState, useSyncExternalStore } from "react";
4
+ import { observer } from "./canvas-observer";
5
+ import { useTranslation } from "react-i18next";
6
+ import { GripVertical } from "lucide-react";
7
+ import {
8
+ DndContext,
9
+ closestCenter,
10
+ PointerSensor,
11
+ useSensor,
12
+ useSensors,
13
+ type DragEndEvent,
14
+ type Modifier,
15
+ } from "@dnd-kit/core";
16
+ import {
17
+ SortableContext,
18
+ verticalListSortingStrategy,
19
+ useSortable,
20
+ } from "@dnd-kit/sortable";
21
+ import { CSS } from "@dnd-kit/utilities";
22
+
23
+ import { detailPageThumbnailBus } from "./detail-page-thumbnail-bus";
24
+ import { FillControl } from "./fill-control";
25
+
26
+ /**
27
+ * Custom Canvas side-panel "페이지" section (hookable-style page list).
28
+ *
29
+ * Two reasons it is custom, not stock:
30
+ * 1. The stock ``PagesPanel`` writes ``store.toDataURL({pageId})`` straight into
31
+ * an <img src>, but this Canvas build's ``toDataURL`` is **async**, so the
32
+ * stock panel shows ``src="[object Promise]"`` (blank). We await + cache it
33
+ * via the workspace and read from the bus here.
34
+ * 2. We want hookable's horizontal rows: drag handle + thumbnail + 역할/제목,
35
+ * drag-to-reorder, and click-to-scroll (the workspace scrolls to the page
36
+ * when it becomes active — see StackedCanvasWorkspace).
37
+ *
38
+ * Reordering uses ``@dnd-kit`` (same as the cardnews thumbnail strip), NOT native
39
+ * HTML5 ``draggable``. Native DnD gives the OS "file drag" ghost + green "+"
40
+ * cursor and drags freely in 2D. dnd-kit keeps the drag pointer-based, locked to
41
+ * the vertical axis, and animates the other rows aside to show the drop target.
42
+ */
43
+
44
+ type TextChild = { type?: string; text?: string; fontSize?: number };
45
+ type PageLike = {
46
+ id: string;
47
+ name?: string;
48
+ computedWidth: number;
49
+ computedHeight: number;
50
+ children?: TextChild[];
51
+ /** 단색 hex 또는 `linear-gradient(...)` 문자열. `FillControl`이 둘 다 만든다. */
52
+ background?: string;
53
+ set?: (props: Record<string, unknown>) => void;
54
+ };
55
+ type StoreLike = {
56
+ pages: PageLike[];
57
+ activePage?: PageLike;
58
+ selectPage: (id: string) => void;
59
+ setPageZIndex: (id: string, index: number) => void;
60
+ };
61
+
62
+ type TFn = (key: string, opts?: Record<string, unknown>) => string;
63
+
64
+ // Decomposer page roles → localized section labels (fallback: raw name).
65
+ function pageLabel(
66
+ page: PageLike,
67
+ index: number,
68
+ t: TFn,
69
+ ): { role: string; title: string } {
70
+ const role = page.name
71
+ ? t(`detailPage.pages.roles.${page.name}`, { defaultValue: page.name })
72
+ : t("detailPage.pages.pageN", { number: index + 1 });
73
+ let title = "";
74
+ let maxFs = -1;
75
+ for (const child of page.children ?? []) {
76
+ if (child.type !== "text") continue;
77
+ const text = (child.text ?? "").trim();
78
+ if (!text) continue;
79
+ const fs = child.fontSize ?? 0;
80
+ if (fs > maxFs) {
81
+ maxFs = fs;
82
+ title = text;
83
+ }
84
+ }
85
+ title = title.replace(/\s+/g, " ").slice(0, 22);
86
+ return { role, title };
87
+ }
88
+
89
+ // Lock the dragged row to the vertical axis (no @dnd-kit/modifiers pkg installed).
90
+ const restrictToVerticalAxis: Modifier = ({ transform }) => ({
91
+ ...transform,
92
+ x: 0,
93
+ });
94
+
95
+ // ── Sortable row ────────────────────────────────────────────────────────────────
96
+
97
+ const PageRow = observer(function PageRow({
98
+ store,
99
+ page,
100
+ index,
101
+ thumb,
102
+ }: {
103
+ store: StoreLike;
104
+ page: PageLike;
105
+ index: number;
106
+ thumb: string | undefined;
107
+ }) {
108
+ const { t } = useTranslation("branding");
109
+ const {
110
+ attributes,
111
+ listeners,
112
+ setNodeRef,
113
+ transform,
114
+ transition,
115
+ isDragging,
116
+ } = useSortable({ id: page.id });
117
+
118
+ const active = store.activePage?.id === page.id;
119
+ const ratio = page.computedHeight / Math.max(1, page.computedWidth);
120
+ const { role, title } = pageLabel(page, index, t);
121
+ const [editingBackground, setEditingBackground] = useState(false);
122
+ const background = page.background || "#ffffff";
123
+
124
+ const style: React.CSSProperties = {
125
+ transform: CSS.Transform.toString(transform),
126
+ transition,
127
+ // Lift the dragged row above its siblings while they animate aside.
128
+ zIndex: isDragging ? 20 : undefined,
129
+ position: isDragging ? "relative" : undefined,
130
+ };
131
+
132
+ return (
133
+ <div
134
+ ref={setNodeRef}
135
+ style={style}
136
+ {...attributes}
137
+ onClick={() => {
138
+ if (!isDragging) store.selectPage(page.id);
139
+ }}
140
+ className={[
141
+ "select-none rounded-xl border bg-white p-2 transition-shadow",
142
+ isDragging
143
+ ? "border-neutral-800 shadow-lg"
144
+ : active
145
+ ? "cursor-pointer border-neutral-800 shadow-sm"
146
+ : "cursor-pointer border-neutral-200 hover:border-neutral-300",
147
+ ].join(" ")}
148
+ >
149
+ <div className="flex items-center gap-2">
150
+ <span
151
+ {...listeners}
152
+ aria-label={t("detailPage.pages.reorderHandle")}
153
+ className={[
154
+ "shrink-0 touch-none rounded text-neutral-300 hover:text-neutral-500",
155
+ isDragging ? "cursor-grabbing" : "cursor-grab",
156
+ ].join(" ")}
157
+ >
158
+ <GripVertical aria-hidden="true" size={16} />
159
+ </span>
160
+ <div
161
+ className="relative shrink-0 overflow-hidden rounded-md border border-neutral-100 bg-neutral-50"
162
+ style={{ width: 64, height: Math.min(80, Math.max(40, 64 * ratio)) }}
163
+ >
164
+ {thumb ? (
165
+ // eslint-disable-next-line @next/next/no-img-element
166
+ <img
167
+ src={thumb}
168
+ alt={title || t("detailPage.pages.pageN", { number: index + 1 })}
169
+ draggable={false}
170
+ className="h-full w-full object-cover object-top"
171
+ />
172
+ ) : null}
173
+ </div>
174
+ <div className="min-w-0 flex-1">
175
+ <p className="truncate text-sm">
176
+ <span className="text-neutral-400">{role}</span>
177
+ {title ? (
178
+ <>
179
+ <span className="mx-1 text-neutral-300">|</span>
180
+ <span className="font-bold text-neutral-900">{title}</span>
181
+ </>
182
+ ) : null}
183
+ </p>
184
+ </div>
185
+ {/*
186
+ 페이지 배경. 우측 인스펙터에도 같은 컨트롤이 있지만 **아무것도 선택 안 됐을 때만**
187
+ 뜬다 — 20섹션짜리 문서를 만지는 동안 선택이 비는 순간이 거의 없어서 사실상 안 보였다.
188
+ 쓰기는 우측과 똑같이 `page.set({background})`이라 상태가 갈라지지 않는다.
189
+ */}
190
+ <button
191
+ type="button"
192
+ onClick={(event) => {
193
+ event.stopPropagation();
194
+ setEditingBackground((open) => !open);
195
+ }}
196
+ aria-label={t("detailPage.pages.background")}
197
+ aria-expanded={editingBackground}
198
+ title={t("detailPage.pages.background")}
199
+ className="h-6 w-6 shrink-0 rounded-md border border-neutral-200 hover:border-neutral-400"
200
+ style={{ background }}
201
+ />
202
+ </div>
203
+ {editingBackground ? (
204
+ <div
205
+ className="mt-2 border-t border-neutral-100 pt-2"
206
+ onClick={(event) => event.stopPropagation()}
207
+ >
208
+ <FillControl
209
+ value={background}
210
+ onChange={(next) => page.set?.({ background: next })}
211
+ />
212
+ </div>
213
+ ) : null}
214
+ </div>
215
+ );
216
+ });
217
+ PageRow.displayName = "PageRow";
218
+
219
+ // ── Panel ─────────────────────────────────────────────────────────────────────
220
+
221
+ export const DetailPagePagesPanel = observer(function DetailPagePagesPanel({
222
+ store,
223
+ }: {
224
+ store: unknown;
225
+ }) {
226
+ const { t } = useTranslation("branding");
227
+ const s = store as StoreLike;
228
+
229
+ // Thumbnails are produced by the workspace (correct Konva instance) and shared
230
+ // through the bus; re-render whenever a new one lands.
231
+ useSyncExternalStore(
232
+ detailPageThumbnailBus.subscribe,
233
+ detailPageThumbnailBus.getVersion,
234
+ detailPageThumbnailBus.getVersion,
235
+ );
236
+
237
+ const sensors = useSensors(
238
+ useSensor(PointerSensor, {
239
+ // A small threshold so a plain click still selects (no drag starts).
240
+ activationConstraint: { distance: 4 },
241
+ }),
242
+ );
243
+
244
+ const handleDragEnd = useCallback(
245
+ (event: DragEndEvent) => {
246
+ const { active, over } = event;
247
+ if (!over || active.id === over.id) return;
248
+ const toIndex = s.pages.findIndex((p) => p.id === over.id);
249
+ if (toIndex === -1) return;
250
+ s.setPageZIndex(active.id as string, toIndex);
251
+ },
252
+ [s],
253
+ );
254
+
255
+ const totalHeight = Math.round(
256
+ s.pages.reduce((acc, p) => acc + p.computedHeight, 0),
257
+ );
258
+ const width = Math.round(s.pages[0]?.computedWidth ?? 0);
259
+
260
+ return (
261
+ <div className="flex h-full flex-col">
262
+ <div className="flex items-baseline justify-between px-4 py-3">
263
+ <p className="text-base font-bold text-neutral-900">
264
+ {t("detailPage.pages.totalPages", { count: s.pages.length })}
265
+ </p>
266
+ <p className="text-xs text-neutral-400">
267
+ {width} × {totalHeight} px
268
+ </p>
269
+ </div>
270
+
271
+ <div className="min-h-0 flex-1 overflow-y-auto px-3 pb-4">
272
+ <DndContext
273
+ sensors={sensors}
274
+ collisionDetection={closestCenter}
275
+ modifiers={[restrictToVerticalAxis]}
276
+ onDragEnd={handleDragEnd}
277
+ >
278
+ <SortableContext
279
+ items={s.pages.map((p) => p.id)}
280
+ strategy={verticalListSortingStrategy}
281
+ >
282
+ <div className="space-y-2">
283
+ {s.pages.map((page, index) => (
284
+ <PageRow
285
+ key={page.id}
286
+ store={s}
287
+ page={page}
288
+ index={index}
289
+ thumb={detailPageThumbnailBus.get(page.id)}
290
+ />
291
+ ))}
292
+ </div>
293
+ </SortableContext>
294
+ </DndContext>
295
+ </div>
296
+ </div>
297
+ );
298
+ });
299
+ DetailPagePagesPanel.displayName = "DetailPagePagesPanel";
@@ -0,0 +1,79 @@
1
+ "use client";
2
+
3
+ /**
4
+ * 작업 영역 아래 붙는 화면 띠 (G7-b).
5
+ *
6
+ * 스톡 ``PagesTimeline``의 자리다. 20~30 화면짜리 문서에서 "지금 몇 번째를 보고
7
+ * 있는가"와 "저기로 건너뛰기"만 하면 되므로, 이 띠는 그 둘만 한다.
8
+ *
9
+ * 썸네일을 **여기서 굽지 않는다.** 굽는 일은 화면 밖 페이지를 잠깐 띄우는 일이라
10
+ * 30장이면 편집기가 눈에 띄게 멈춘다. 이미 구워져 있으면(페이지 패널을 한 번 열면
11
+ * 그때 굽는다) 그 그림을 쓰고, 없으면 번호 칩으로 둔다.
12
+ */
13
+
14
+ import { useSyncExternalStore } from "react";
15
+
16
+ import { detailPageThumbnailBus } from "./detail-page-thumbnail-bus";
17
+ import { observer } from "./canvas-observer";
18
+
19
+ type PageLike = { id: string; name?: unknown };
20
+ type StoreLike = {
21
+ pages: PageLike[];
22
+ activePage?: { id: string } | null;
23
+ selectPage: (id: string) => void;
24
+ };
25
+
26
+ export const DetailPagePagesTimeline = observer(function DetailPagePagesTimeline({
27
+ store,
28
+ }: {
29
+ store: unknown;
30
+ }) {
31
+ const s = store as StoreLike;
32
+ useSyncExternalStore(
33
+ detailPageThumbnailBus.subscribe,
34
+ detailPageThumbnailBus.getVersion,
35
+ () => 0,
36
+ );
37
+ const activeId = s.activePage?.id;
38
+
39
+ if (s.pages.length < 2) return null;
40
+
41
+ return (
42
+ <div
43
+ data-dp-pages-timeline=""
44
+ className="pointer-events-auto absolute inset-x-0 bottom-0 z-20 flex items-center gap-1.5 overflow-x-auto border-t border-neutral-200 bg-white/95 px-3 py-1.5 backdrop-blur-sm"
45
+ >
46
+ {s.pages.map((page, index) => {
47
+ const active = page.id === activeId;
48
+ const thumb = detailPageThumbnailBus.get(page.id);
49
+ return (
50
+ <button
51
+ key={page.id}
52
+ type="button"
53
+ onClick={() => s.selectPage(page.id)}
54
+ aria-current={active ? "true" : undefined}
55
+ title={typeof page.name === "string" ? page.name : `${index + 1}`}
56
+ className={[
57
+ "flex h-10 w-8 shrink-0 items-center justify-center overflow-hidden rounded border text-[11px] font-semibold transition-colors",
58
+ active
59
+ ? "border-neutral-900 bg-neutral-900 text-white"
60
+ : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-400",
61
+ ].join(" ")}
62
+ >
63
+ {thumb ? (
64
+ // eslint-disable-next-line @next/next/no-img-element
65
+ <img
66
+ src={thumb}
67
+ alt={`${index + 1}`}
68
+ className="h-full w-full object-cover"
69
+ />
70
+ ) : (
71
+ index + 1
72
+ )}
73
+ </button>
74
+ );
75
+ })}
76
+ </div>
77
+ );
78
+ });
79
+ DetailPagePagesTimeline.displayName = "DetailPagePagesTimeline";
@@ -0,0 +1,257 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { useInfiniteQuery } from "@tanstack/react-query";
5
+ import { ImageOff, Loader2, Upload } from "lucide-react";
6
+ import { useTranslation } from "react-i18next";
7
+
8
+ import { PanelSearchInput } from "./panel-search-input";
9
+ import { isAnimatedFile } from "../../lib/detail-page/animation-sniff";
10
+ import { insertPersonalImage } from "../../lib/detail-page/insert-image";
11
+ import { useDetailPageHost } from "./detail-page-host-context";
12
+ import {
13
+ mirrorStockPhoto,
14
+ searchStockPhotos,
15
+ type StockPhoto,
16
+ } from "../../lib/detail-page/stock-photos";
17
+
18
+ /**
19
+ * "사진" 패널 — 올리기 + 무료 스톡 사진 검색(Pexels).
20
+ *
21
+ * 고른 사진은 우리 S3로 옮겨 담은 뒤 얹는다(`mirrorStockPhoto`). 남의 주소를 문서에
22
+ * 박으면 저장된 상세페이지가 그 서버 수명에 묶인다.
23
+ *
24
+ * 하단의 "Pexels 제공" 링크와 사진별 작가 표기는 장식이 아니라 **제공처가 요구하는
25
+ * 출처 표기**다 — 지우면 이용 조건을 어긴다.
26
+ */
27
+
28
+ const PER_PAGE = 24;
29
+ const DEBOUNCE_MS = 400;
30
+
31
+ export function DetailPagePhotosPanel({
32
+ store,
33
+ uploadFile,
34
+ }: {
35
+ store: unknown;
36
+ /** 파일 → 접근 가능한 URL. 편집기가 자기 업로더를 그대로 넘긴다. */
37
+ uploadFile?: (file: File) => Promise<string>;
38
+ }) {
39
+ const { t } = useTranslation("branding");
40
+ const { queryKeys } = useDetailPageHost();
41
+ const inputRef = useRef<HTMLInputElement>(null);
42
+ const [busy, setBusy] = useState(false);
43
+ const [error, setError] = useState<string | null>(null);
44
+ // 이번 편집 동안 올린 것들. 새로고침하면 사라진다 — 오래 두는 자리는 "브랜드 이미지"다.
45
+ const [recent, setRecent] = useState<string[]>([]);
46
+ const [query, setQuery] = useState("");
47
+ const [debounced, setDebounced] = useState("");
48
+ /** 지금 옮겨 담는 중인 사진. 타일 하나에만 스피너를 씌운다. */
49
+ const [inserting, setInserting] = useState<string | null>(null);
50
+
51
+ useEffect(() => {
52
+ const timer = setTimeout(() => setDebounced(query.trim()), DEBOUNCE_MS);
53
+ return () => clearTimeout(timer);
54
+ }, [query]);
55
+
56
+ const stock = useInfiniteQuery({
57
+ queryKey: queryKeys.branding.detailPageStockPhotos(debounced),
58
+ queryFn: ({ pageParam, signal }) =>
59
+ searchStockPhotos({
60
+ query: debounced,
61
+ page: pageParam,
62
+ perPage: PER_PAGE,
63
+ signal,
64
+ }),
65
+ initialPageParam: 1,
66
+ getNextPageParam: (last, all) => (last.hasMore ? all.length + 1 : undefined),
67
+ staleTime: 10 * 60_000,
68
+ retry: false,
69
+ });
70
+
71
+ const pages = stock.data?.pages ?? [];
72
+ const photos = pages.flatMap((page) => page.photos);
73
+ const configured = pages[0]?.configured ?? true;
74
+
75
+ const take = useCallback(
76
+ async (files: FileList | null) => {
77
+ if (!files?.length || !uploadFile) return;
78
+ setBusy(true);
79
+ setError(null);
80
+ try {
81
+ for (const file of Array.from(files)) {
82
+ const url = await uploadFile(file);
83
+ setRecent((prev) => [url, ...prev]);
84
+ // 확장자·MIME 으로는 가를 수 없다 — 움직이는 WebP 와 정지 WebP 가 둘 다
85
+ // image/webp 라서, 바이트를 봐야 애니메이션 태그가 정확히 붙는다.
86
+ insertPersonalImage(store, url, { isGif: await isAnimatedFile(file) });
87
+ }
88
+ } catch {
89
+ setError(t("detailPage.photos.uploadFailed"));
90
+ } finally {
91
+ setBusy(false);
92
+ }
93
+ },
94
+ [store, t, uploadFile],
95
+ );
96
+
97
+ const insertStock = useCallback(
98
+ async (photo: StockPhoto) => {
99
+ setInserting(photo.id);
100
+ setError(null);
101
+ try {
102
+ const src = await mirrorStockPhoto(photo, uploadFile);
103
+ insertPersonalImage(store, src);
104
+ } catch {
105
+ setError(t("detailPage.photos.stockInsertFailed"));
106
+ } finally {
107
+ setInserting(null);
108
+ }
109
+ },
110
+ [store, t, uploadFile],
111
+ );
112
+
113
+ return (
114
+ <div className="flex h-full flex-col gap-3 overflow-y-auto p-3">
115
+ <button
116
+ type="button"
117
+ disabled={!uploadFile || busy}
118
+ onClick={() => inputRef.current?.click()}
119
+ className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-neutral-300 px-3 py-4 text-sm font-medium text-neutral-600 hover:border-neutral-400 disabled:cursor-not-allowed disabled:opacity-50"
120
+ >
121
+ {busy ? (
122
+ <Loader2 aria-hidden="true" className="animate-spin" size={16} />
123
+ ) : (
124
+ <Upload aria-hidden="true" size={16} />
125
+ )}
126
+ {t("detailPage.photos.upload")}
127
+ </button>
128
+ <input
129
+ ref={inputRef}
130
+ type="file"
131
+ accept="image/*"
132
+ multiple
133
+ hidden
134
+ onChange={(event) => {
135
+ void take(event.target.files);
136
+ event.target.value = "";
137
+ }}
138
+ />
139
+
140
+ {error ? <p className="text-xs text-red-600">{error}</p> : null}
141
+
142
+ {recent.length ? (
143
+ <div className="grid grid-cols-3 gap-2">
144
+ {recent.map((url) => (
145
+ <button
146
+ key={url}
147
+ type="button"
148
+ onClick={() => insertPersonalImage(store, url)}
149
+ className="aspect-square overflow-hidden rounded-lg border border-neutral-200 hover:border-neutral-400"
150
+ title={t("detailPage.shapes.insertHint")}
151
+ >
152
+ {/* eslint-disable-next-line @next/next/no-img-element */}
153
+ <img src={url} alt="" className="h-full w-full object-cover" />
154
+ </button>
155
+ ))}
156
+ </div>
157
+ ) : null}
158
+
159
+ <PanelSearchInput
160
+ value={query}
161
+ onChange={setQuery}
162
+ placeholder={t("detailPage.photos.searchPlaceholder")}
163
+ label={t("detailPage.photos.searchLabel")}
164
+ />
165
+
166
+ {!configured ? (
167
+ <p className="px-1 text-xs leading-relaxed text-neutral-500">
168
+ {t("detailPage.photos.stockUnavailable")}
169
+ </p>
170
+ ) : stock.isLoading ? (
171
+ <div className="flex justify-center py-6 text-neutral-400">
172
+ <Loader2 aria-hidden="true" className="animate-spin" size={20} />
173
+ </div>
174
+ ) : stock.error ? (
175
+ <p className="text-xs text-red-600">
176
+ {t("detailPage.photos.stockFailed")}
177
+ </p>
178
+ ) : photos.length === 0 ? (
179
+ <div className="flex flex-col items-center gap-2 py-6 text-center text-neutral-400">
180
+ <ImageOff aria-hidden="true" size={20} />
181
+ <p className="text-xs">{t("detailPage.photos.stockEmpty")}</p>
182
+ </div>
183
+ ) : (
184
+ <>
185
+ <div className="grid grid-cols-2 gap-2">
186
+ {photos.map((photo) => (
187
+ <div
188
+ key={photo.id}
189
+ className="group relative overflow-hidden rounded-lg border border-neutral-200 hover:border-neutral-400"
190
+ >
191
+ <button
192
+ type="button"
193
+ disabled={inserting !== null}
194
+ onClick={() => void insertStock(photo)}
195
+ className="block w-full disabled:cursor-wait"
196
+ title={t("detailPage.photos.stockInsertHint")}
197
+ >
198
+ {/* eslint-disable-next-line @next/next/no-img-element */}
199
+ <img
200
+ src={photo.thumb}
201
+ alt={photo.alt}
202
+ loading="lazy"
203
+ className="h-24 w-full object-cover transition group-hover:opacity-90"
204
+ />
205
+ {inserting === photo.id ? (
206
+ <span className="absolute inset-0 flex items-center justify-center bg-white/70">
207
+ <Loader2
208
+ aria-hidden="true"
209
+ className="animate-spin text-neutral-500"
210
+ size={18}
211
+ />
212
+ </span>
213
+ ) : null}
214
+ </button>
215
+ {/* 작가 표기 — 제공처 이용 조건이 요구한다. */}
216
+ <a
217
+ href={photo.pageUrl}
218
+ target="_blank"
219
+ rel="noreferrer noopener"
220
+ className="absolute bottom-0 left-0 right-0 hidden truncate bg-black/60 px-1.5 py-1 text-[10px] text-white group-hover:block hover:underline"
221
+ >
222
+ {photo.photographer}
223
+ </a>
224
+ </div>
225
+ ))}
226
+ </div>
227
+
228
+ {stock.hasNextPage ? (
229
+ <button
230
+ type="button"
231
+ disabled={stock.isFetchingNextPage}
232
+ onClick={() => void stock.fetchNextPage()}
233
+ className="mx-auto flex items-center gap-1.5 rounded-md border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-600 hover:border-neutral-400 disabled:opacity-50"
234
+ >
235
+ {stock.isFetchingNextPage ? (
236
+ <Loader2 aria-hidden="true" className="animate-spin" size={12} />
237
+ ) : null}
238
+ {t("detailPage.photos.stockMore")}
239
+ </button>
240
+ ) : null}
241
+ </>
242
+ )}
243
+
244
+ <p className="mt-auto px-1 pt-1 text-xs leading-relaxed text-neutral-500">
245
+ {t("detailPage.photos.hint")}{" "}
246
+ <a
247
+ href="https://www.pexels.com"
248
+ target="_blank"
249
+ rel="noreferrer noopener"
250
+ className="underline underline-offset-2"
251
+ >
252
+ {t("detailPage.photos.stockCredit")}
253
+ </a>
254
+ </p>
255
+ </div>
256
+ );
257
+ }