@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,179 @@
1
+ "use client";
2
+
3
+ import { useRef, useState, type ChangeEvent } from "react";
4
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
+ import { ExternalLink, ImageOff, Loader2, Trash2, Upload } from "lucide-react";
6
+ import Link from "next/link";
7
+ import { useTranslation } from "react-i18next";
8
+
9
+ import { BrandPanelHeader } from "./detail-page-brand-panel-header";
10
+ import { useDetailPageHost } from "./detail-page-host-context";
11
+ import type {
12
+ BrandAsset,
13
+ BrandAssetKind,
14
+ } from "./detail-page-host-context";
15
+ import { insertPersonalImage } from "../../lib/detail-page/insert-image";
16
+
17
+ const UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp,image/svg+xml";
18
+ const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
19
+
20
+ function isImageAsset(asset: BrandAsset): boolean {
21
+ return ["logo", "product_image", "model_image", "image", "svg"].includes(
22
+ asset.asset_type,
23
+ );
24
+ }
25
+
26
+ function uploadKind(file: File): BrandAssetKind {
27
+ if (file.type === "image/svg+xml") return "svg";
28
+ return "image";
29
+ }
30
+
31
+ export function DetailPageMyImagesPanel({ store }: { store: unknown }) {
32
+ const { brand, queryKeys, toast } = useDetailPageHost();
33
+ const { t } = useTranslation("branding");
34
+ const queryClient = useQueryClient();
35
+ const {
36
+ activeBrand,
37
+ activeBrandId,
38
+ isLoading: brandsLoading,
39
+ } = brand.useBrandWorkspace();
40
+ const [error, setError] = useState<string | null>(null);
41
+ const fileRef = useRef<HTMLInputElement>(null);
42
+ // GIF는 '브랜드 GIF' 패널이 맡는다 — 사진과 한 그리드에 섞이면 고르기 어렵다.
43
+ const assetsQuery = useQuery({
44
+ queryKey: queryKeys.branding.brandAssets(activeBrandId, "image"),
45
+ queryFn: ({ signal }) => brand.listBrandAssets(activeBrandId!, signal, "image"),
46
+ enabled: Boolean(activeBrandId),
47
+ staleTime: 4 * 60_000,
48
+ });
49
+ const items = (assetsQuery.data ?? []).filter(isImageAsset);
50
+
51
+ const uploadMutation = useMutation({
52
+ mutationFn: async (file: File) => {
53
+ if (!activeBrandId) throw new Error("brand-not-selected");
54
+ return brand.uploadBrandAsset(activeBrandId, file, uploadKind(file), {
55
+ metadata: { source: "canvas_upload" },
56
+ });
57
+ },
58
+ onSuccess: async () => {
59
+ setError(null);
60
+ await queryClient.invalidateQueries({
61
+ queryKey: queryKeys.branding.brandAssets(activeBrandId),
62
+ });
63
+ },
64
+ onError: () => setError(t("detailPage.brandAssets.uploadFailed")),
65
+ });
66
+
67
+ const deleteMutation = useMutation({
68
+ mutationFn: (asset: BrandAsset) => brand.deleteBrandAsset(asset),
69
+ onSuccess: async () => {
70
+ setError(null);
71
+ await queryClient.invalidateQueries({
72
+ queryKey: queryKeys.branding.brandAssets(activeBrandId),
73
+ });
74
+ },
75
+ onError: () => toast.error(t("detailPage.brandAssets.deleteFailed")),
76
+ });
77
+
78
+ const handleUpload = (event: ChangeEvent<HTMLInputElement>) => {
79
+ const file = event.target.files?.[0];
80
+ event.target.value = "";
81
+ if (!file) return;
82
+ if (file.size > MAX_UPLOAD_BYTES) {
83
+ setError(t("detailPage.brandAssets.uploadTooLarge"));
84
+ return;
85
+ }
86
+ uploadMutation.mutate(file);
87
+ };
88
+
89
+ return (
90
+ <div className="flex h-full flex-col p-4">
91
+ <BrandPanelHeader onRefresh={() => void assetsQuery.refetch()} />
92
+
93
+ <label className="mb-3 flex h-16 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed border-neutral-300 text-neutral-400 hover:border-neutral-400 hover:text-neutral-600">
94
+ {uploadMutation.isPending ? (
95
+ <Loader2 aria-hidden="true" className="animate-spin" size={18} />
96
+ ) : (
97
+ <Upload aria-hidden="true" size={18} />
98
+ )}
99
+ <span className="text-[11px]">
100
+ {t("detailPage.brandAssets.uploadImage")}
101
+ </span>
102
+ <input
103
+ ref={fileRef}
104
+ type="file"
105
+ accept={UPLOAD_ACCEPT}
106
+ className="hidden"
107
+ onChange={handleUpload}
108
+ />
109
+ </label>
110
+
111
+ {brandsLoading || assetsQuery.isLoading ? (
112
+ <div className="flex flex-1 items-center justify-center text-neutral-400">
113
+ <Loader2 aria-hidden="true" className="animate-spin" size={22} />
114
+ </div>
115
+ ) : error || assetsQuery.error ? (
116
+ <p className="text-xs font-medium text-red-600">
117
+ {error ?? t("detailPage.brandAssets.loadFailed")}
118
+ </p>
119
+ ) : !activeBrand ? (
120
+ <p className="text-xs text-neutral-400">
121
+ {t("detailPage.brandAssets.noBrand")}
122
+ </p>
123
+ ) : items.length === 0 ? (
124
+ <div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-neutral-400">
125
+ <ImageOff aria-hidden="true" size={22} />
126
+ <p className="text-xs">{t("detailPage.brandAssets.imagesEmpty")}</p>
127
+ <Link
128
+ href="/branding/moodboard"
129
+ className="inline-flex items-center gap-1 text-[11px] font-medium text-neutral-600 underline underline-offset-2"
130
+ >
131
+ {t("detailPage.brandAssets.openMoodboard")}
132
+ <ExternalLink size={11} />
133
+ </Link>
134
+ </div>
135
+ ) : (
136
+ <div className="grid grid-cols-2 gap-2 overflow-y-auto">
137
+ {items.map((asset) => (
138
+ <div
139
+ key={asset.id}
140
+ className="group relative overflow-hidden rounded-md border border-neutral-200 hover:border-neutral-500"
141
+ >
142
+ <button
143
+ type="button"
144
+ disabled={!asset.download_url}
145
+ onClick={() =>
146
+ // presigned URL(download_url)은 15분이면 죽는다. 문서에 남는 주소는
147
+ // 만료 없는 경로여야 다시 열 때 403이 안 난다.
148
+ asset.download_url &&
149
+ insertPersonalImage(store, brand.brandAssetDocumentSrc(asset))
150
+ }
151
+ className="block w-full disabled:opacity-50"
152
+ title={t("detailPage.brandAssets.insertHint")}
153
+ >
154
+ {/* eslint-disable-next-line @next/next/no-img-element */}
155
+ <img
156
+ src={asset.download_url ?? ""}
157
+ crossOrigin="anonymous"
158
+ alt={asset.display_name ?? asset.filename}
159
+ className="h-24 w-full object-cover transition group-hover:opacity-90"
160
+ />
161
+ </button>
162
+ <span className="pointer-events-none absolute bottom-1.5 left-1.5 max-w-[calc(100%-12px)] truncate rounded-sm bg-black/70 px-1.5 py-0.5 text-[10px] text-white">
163
+ {asset.display_name ?? asset.filename}
164
+ </span>
165
+ <button
166
+ type="button"
167
+ onClick={() => deleteMutation.mutate(asset)}
168
+ className="absolute right-1 top-1 hidden h-6 w-6 items-center justify-center rounded-md bg-white/95 text-neutral-400 shadow-sm hover:text-red-600 group-hover:flex"
169
+ aria-label={t("detailPage.brandAssets.delete")}
170
+ >
171
+ <Trash2 aria-hidden="true" size={13} />
172
+ </button>
173
+ </div>
174
+ ))}
175
+ </div>
176
+ )}
177
+ </div>
178
+ );
179
+ }
@@ -0,0 +1,174 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
+ import { Loader2, Shapes, Trash2, Upload } from "lucide-react";
6
+ import { useTranslation } from "react-i18next";
7
+
8
+ import { BrandPanelHeader } from "./detail-page-brand-panel-header";
9
+ import { useDetailPageHost } from "./detail-page-host-context";
10
+ import type { BrandAsset } from "./detail-page-host-context";
11
+ import { insertShape } from "../../lib/detail-page/insert-shape";
12
+
13
+ const MAX_UPLOAD_BYTES = 500_000;
14
+
15
+ function isShape(asset: BrandAsset): boolean {
16
+ return asset.asset_type === "shape" || asset.asset_type === "svg";
17
+ }
18
+
19
+ export function DetailPageMyShapesPanel({ store }: { store: unknown }) {
20
+ const { brand, queryKeys, toast } = useDetailPageHost();
21
+ const { t } = useTranslation("branding");
22
+ const queryClient = useQueryClient();
23
+ const {
24
+ activeBrand,
25
+ activeBrandId,
26
+ isLoading: brandsLoading,
27
+ } = brand.useBrandWorkspace();
28
+ const [insertingId, setInsertingId] = useState<string | null>(null);
29
+ const assetsQuery = useQuery({
30
+ queryKey: queryKeys.branding.brandAssets(activeBrandId),
31
+ queryFn: ({ signal }) => brand.listBrandAssets(activeBrandId!, signal),
32
+ enabled: Boolean(activeBrandId),
33
+ staleTime: 4 * 60_000,
34
+ });
35
+ const items = (assetsQuery.data ?? []).filter(isShape);
36
+
37
+ const invalidate = () =>
38
+ queryClient.invalidateQueries({
39
+ queryKey: queryKeys.branding.brandAssets(activeBrandId),
40
+ });
41
+
42
+ const uploadMutation = useMutation({
43
+ mutationFn: async (file: File) => {
44
+ if (!activeBrandId) throw new Error("brand-not-selected");
45
+ return brand.uploadBrandAsset(activeBrandId, file, "shape", {
46
+ metadata: { source: "canvas_upload" },
47
+ });
48
+ },
49
+ onSuccess: async () => {
50
+ await invalidate();
51
+ toast.success(t("detailPage.brandAssets.shapeUploaded"));
52
+ },
53
+ onError: () => toast.error(t("detailPage.brandAssets.uploadFailed")),
54
+ });
55
+
56
+ const deleteMutation = useMutation({
57
+ mutationFn: (asset: BrandAsset) => brand.deleteBrandAsset(asset),
58
+ onSuccess: invalidate,
59
+ onError: () => toast.error(t("detailPage.brandAssets.deleteFailed")),
60
+ });
61
+
62
+ const insert = async (asset: BrandAsset) => {
63
+ if (!asset.download_url) return;
64
+ setInsertingId(asset.id);
65
+ try {
66
+ const response = await fetch(asset.download_url);
67
+ if (!response.ok) throw new Error("download failed");
68
+ const svg = await response.text();
69
+ insertShape(
70
+ store,
71
+ svg,
72
+ typeof asset.metadata.view_box === "string"
73
+ ? asset.metadata.view_box
74
+ : undefined,
75
+ );
76
+ } catch {
77
+ toast.error(t("detailPage.brandAssets.shapeLoadFailed"));
78
+ } finally {
79
+ setInsertingId(null);
80
+ }
81
+ };
82
+
83
+ return (
84
+ <div className="flex h-full flex-col p-4">
85
+ <BrandPanelHeader onRefresh={() => void assetsQuery.refetch()} />
86
+
87
+ <label className="mb-3 flex h-16 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed border-neutral-300 text-neutral-400 hover:border-neutral-400 hover:text-neutral-600">
88
+ {uploadMutation.isPending ? (
89
+ <Loader2 aria-hidden="true" className="animate-spin" size={18} />
90
+ ) : (
91
+ <Upload aria-hidden="true" size={18} />
92
+ )}
93
+ <span className="text-[11px]">
94
+ {t("detailPage.brandAssets.uploadShape")}
95
+ </span>
96
+ <input
97
+ type="file"
98
+ accept=".svg,image/svg+xml"
99
+ className="hidden"
100
+ onChange={(event) => {
101
+ const file = event.target.files?.[0];
102
+ event.target.value = "";
103
+ if (!file) return;
104
+ if (
105
+ file.size > MAX_UPLOAD_BYTES ||
106
+ (!file.name.toLowerCase().endsWith(".svg") &&
107
+ file.type !== "image/svg+xml")
108
+ ) {
109
+ toast.error(t("detailPage.brandAssets.invalidShape"));
110
+ return;
111
+ }
112
+ uploadMutation.mutate(file);
113
+ }}
114
+ />
115
+ </label>
116
+
117
+ {brandsLoading || assetsQuery.isLoading ? (
118
+ <div className="flex flex-1 items-center justify-center text-neutral-400">
119
+ <Loader2 aria-hidden="true" className="animate-spin" size={22} />
120
+ </div>
121
+ ) : assetsQuery.error ? (
122
+ <p className="text-xs font-medium text-red-600">
123
+ {t("detailPage.brandAssets.loadFailed")}
124
+ </p>
125
+ ) : !activeBrand ? (
126
+ <p className="text-xs text-neutral-400">
127
+ {t("detailPage.brandAssets.noBrand")}
128
+ </p>
129
+ ) : items.length === 0 ? (
130
+ <div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-neutral-400">
131
+ <Shapes aria-hidden="true" size={22} />
132
+ <p className="text-xs">{t("detailPage.brandAssets.shapesEmpty")}</p>
133
+ </div>
134
+ ) : (
135
+ <div className="grid grid-cols-3 gap-2 overflow-y-auto">
136
+ {items.map((asset) => (
137
+ <div
138
+ key={asset.id}
139
+ className="group relative aspect-square rounded-md border border-neutral-200 hover:border-neutral-500"
140
+ >
141
+ <button
142
+ type="button"
143
+ onClick={() => void insert(asset)}
144
+ disabled={!asset.download_url || insertingId === asset.id}
145
+ className="flex h-full w-full items-center justify-center p-2 disabled:opacity-50"
146
+ title={t("detailPage.brandAssets.insertHint")}
147
+ >
148
+ {insertingId === asset.id ? (
149
+ <Loader2 className="animate-spin text-neutral-400" size={18} />
150
+ ) : (
151
+ // eslint-disable-next-line @next/next/no-img-element
152
+ <img
153
+ src={asset.download_url ?? ""}
154
+ crossOrigin="anonymous"
155
+ alt={asset.display_name ?? asset.filename}
156
+ className="max-h-full max-w-full object-contain"
157
+ />
158
+ )}
159
+ </button>
160
+ <button
161
+ type="button"
162
+ onClick={() => deleteMutation.mutate(asset)}
163
+ className="absolute right-1 top-1 hidden h-6 w-6 items-center justify-center rounded-md bg-white/95 text-neutral-400 shadow-sm hover:text-red-600 group-hover:flex"
164
+ aria-label={t("detailPage.brandAssets.delete")}
165
+ >
166
+ <Trash2 aria-hidden="true" size={13} />
167
+ </button>
168
+ </div>
169
+ ))}
170
+ </div>
171
+ )}
172
+ </div>
173
+ );
174
+ }
@@ -0,0 +1,171 @@
1
+ "use client";
2
+
3
+ import { useSyncExternalStore } from "react";
4
+ import { observer } from "./canvas-observer";
5
+ import { useTranslation } from "react-i18next";
6
+ import {
7
+ Sparkles,
8
+ ChevronUp,
9
+ ChevronDown,
10
+ Copy,
11
+ Plus,
12
+ Trash2,
13
+ Wand2,
14
+ } from "lucide-react";
15
+
16
+ import {
17
+ isSectionReauthorWired,
18
+ requestSectionReauthor,
19
+ subscribeSectionReauthorAvailability,
20
+ } from "../../lib/detail-page/section-reauthor-bus";
21
+
22
+ /**
23
+ * hookable-style per-section (per-page) floating quick toolbar.
24
+ *
25
+ * Renders a vertical pill next to the active page with: AI, move up, move down,
26
+ * duplicate, add, delete. The actions mirror the stock editor's stock ``<PageControls>``
27
+ * (`page.setZIndex` / `page.clone` / `store.addPage` / `store.deletePages`) so
28
+ * behaviour matches the SDK exactly — we just restyle it as a vertical rail and
29
+ * disable the stock controls in the workspace.
30
+ */
31
+
32
+ type PageLike = {
33
+ id: string;
34
+ bleed?: number;
35
+ width?: number | string;
36
+ height?: number | string;
37
+ setZIndex: (index: number) => void;
38
+ clone: () => void;
39
+ };
40
+ type StoreLike = {
41
+ pages: PageLike[];
42
+ activePage?: { id: string; bleed?: number; width?: number | string; height?: number | string };
43
+ addPage: (props: Record<string, unknown>) => PageLike;
44
+ deletePages: (ids: string[]) => void;
45
+ openSidePanel?: (name: string) => void;
46
+ };
47
+
48
+ function IconButton({
49
+ label,
50
+ onClick,
51
+ disabled,
52
+ children,
53
+ }: {
54
+ label: string;
55
+ onClick: () => void;
56
+ disabled?: boolean;
57
+ children: React.ReactNode;
58
+ }) {
59
+ return (
60
+ <button
61
+ type="button"
62
+ title={label}
63
+ aria-label={label}
64
+ disabled={disabled}
65
+ onClick={(e) => {
66
+ e.stopPropagation();
67
+ onClick();
68
+ }}
69
+ className={[
70
+ "flex h-8 w-8 items-center justify-center rounded-lg transition-colors",
71
+ disabled
72
+ ? "cursor-not-allowed text-neutral-300"
73
+ : "text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900",
74
+ ].join(" ")}
75
+ >
76
+ {children}
77
+ </button>
78
+ );
79
+ }
80
+
81
+ export const DetailPagePageToolbar = observer(function DetailPagePageToolbar({
82
+ store,
83
+ page,
84
+ }: {
85
+ store: unknown;
86
+ page: unknown;
87
+ }) {
88
+ const { t } = useTranslation("branding");
89
+ // 툴바가 편집기보다 먼저 그려질 수 있어 구독으로 읽는다(한 번 읽으면 계속 숨는다).
90
+ const reauthorWired = useSyncExternalStore(
91
+ subscribeSectionReauthorAvailability,
92
+ isSectionReauthorWired,
93
+ () => false,
94
+ );
95
+ const s = store as StoreLike;
96
+ const p = page as PageLike;
97
+ const index = s.pages.findIndex((pg) => pg.id === p.id);
98
+ const isFirst = index <= 0;
99
+ const isLast = index === s.pages.length - 1;
100
+ const canDelete = s.pages.length > 1;
101
+
102
+ const addAfter = () => {
103
+ const ref = s.activePage ?? s.pages[index];
104
+ const next = s.addPage({
105
+ bleed: ref?.bleed ?? 0,
106
+ width: ref?.width ?? "auto",
107
+ height: ref?.height ?? "auto",
108
+ });
109
+ next.setZIndex(index + 1);
110
+ };
111
+
112
+ return (
113
+ <div className="flex flex-col items-center gap-0.5 rounded-2xl border border-neutral-200 bg-white/95 px-1 py-1.5 shadow-md backdrop-blur-sm">
114
+ <button
115
+ type="button"
116
+ title={t("detailPage.pageToolbar.aiGenerate")}
117
+ aria-label={t("detailPage.pageToolbar.aiGenerate")}
118
+ onClick={(e) => {
119
+ e.stopPropagation();
120
+ s.openSidePanel?.("ai-generate");
121
+ }}
122
+ className="flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-neutral-100"
123
+ >
124
+ <Sparkles size={17} className="text-fuchsia-500" />
125
+ </button>
126
+
127
+ {/* 이 화면만 마크업째 다시 만든다 — 슬롯 카피 편집과 달리 칸 수·표 같은 구조가
128
+ 바뀔 수 있다. 요청만 띄우고 실제 일(모달·API·페이지 교체)은 편집기가 한다. */}
129
+ {reauthorWired ? (
130
+ <button
131
+ type="button"
132
+ title={t("detailPage.pageToolbar.reauthor", {
133
+ defaultValue: "이 화면 다시 만들기",
134
+ })}
135
+ aria-label={t("detailPage.pageToolbar.reauthor", {
136
+ defaultValue: "이 화면 다시 만들기",
137
+ })}
138
+ onClick={(e) => {
139
+ e.stopPropagation();
140
+ requestSectionReauthor(p.id);
141
+ }}
142
+ className="flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-neutral-100"
143
+ >
144
+ <Wand2 size={16} className="text-violet-500" />
145
+ </button>
146
+ ) : null}
147
+
148
+ <hr className="my-0.5 w-5 border-neutral-200" />
149
+
150
+ <IconButton label={t("detailPage.pageToolbar.moveUp")} onClick={() => p.setZIndex(index - 1)} disabled={isFirst}>
151
+ <ChevronUp size={17} />
152
+ </IconButton>
153
+ <IconButton label={t("detailPage.pageToolbar.moveDown")} onClick={() => p.setZIndex(index + 1)} disabled={isLast}>
154
+ <ChevronDown size={17} />
155
+ </IconButton>
156
+ <IconButton label={t("detailPage.pageToolbar.duplicate")} onClick={() => p.clone()}>
157
+ <Copy size={16} />
158
+ </IconButton>
159
+ <IconButton label={t("detailPage.pageToolbar.addBelow")} onClick={addAfter}>
160
+ <Plus size={17} />
161
+ </IconButton>
162
+
163
+ <hr className="my-0.5 w-5 border-neutral-200" />
164
+
165
+ <IconButton label={t("detailPage.pageToolbar.delete")} onClick={() => s.deletePages([p.id])} disabled={!canDelete}>
166
+ <Trash2 size={16} />
167
+ </IconButton>
168
+ </div>
169
+ );
170
+ });
171
+ DetailPagePageToolbar.displayName = "DetailPagePageToolbar";