@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.
- package/components/cardnews/color-input.tsx +380 -0
- package/components/detail-page/ai-generate-panel.tsx +763 -0
- package/components/detail-page/annotation-canvas.tsx +860 -0
- package/components/detail-page/annotation-dialog.tsx +397 -0
- package/components/detail-page/bubble-tail-overlay.tsx +209 -0
- package/components/detail-page/canvas-context-menu.tsx +234 -0
- package/components/detail-page/canvas-observer.tsx +68 -0
- package/components/detail-page/canvas-overlay-host.tsx +44 -0
- package/components/detail-page/canvas-store-facade.ts +51 -0
- package/components/detail-page/chart-inspector.tsx +531 -0
- package/components/detail-page/design-reference-picker.tsx +383 -0
- package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
- package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
- package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
- package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
- package/components/detail-page/detail-page-charts-panel.tsx +104 -0
- package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
- package/components/detail-page/detail-page-download-dialog.tsx +524 -0
- package/components/detail-page/detail-page-editor.tsx +974 -0
- package/components/detail-page/detail-page-elements-panel.tsx +117 -0
- package/components/detail-page/detail-page-font-picker.tsx +231 -0
- package/components/detail-page/detail-page-history-buttons.tsx +87 -0
- package/components/detail-page/detail-page-host-context.tsx +465 -0
- package/components/detail-page/detail-page-icons-panel.tsx +251 -0
- package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
- package/components/detail-page/detail-page-layers-panel.tsx +618 -0
- package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
- package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
- package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
- package/components/detail-page/detail-page-pages-panel.tsx +299 -0
- package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
- package/components/detail-page/detail-page-photos-panel.tsx +257 -0
- package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
- package/components/detail-page/detail-page-qr-panel.tsx +137 -0
- package/components/detail-page/detail-page-references-panel.tsx +166 -0
- package/components/detail-page/detail-page-selection.ts +44 -0
- package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
- package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
- package/components/detail-page/detail-page-tables-panel.tsx +106 -0
- package/components/detail-page/detail-page-text-panel.tsx +179 -0
- package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
- package/components/detail-page/edit-quota-ui.tsx +150 -0
- package/components/detail-page/editor-history.ts +56 -0
- package/components/detail-page/editor-hotkeys.tsx +165 -0
- package/components/detail-page/element-recents-strip.tsx +105 -0
- package/components/detail-page/element-rects.ts +82 -0
- package/components/detail-page/fill-control.tsx +261 -0
- package/components/detail-page/find-replace-panel.tsx +252 -0
- package/components/detail-page/generation-progress-fill.tsx +57 -0
- package/components/detail-page/gif-animator.tsx +184 -0
- package/components/detail-page/gif-effect-picker.tsx +136 -0
- package/components/detail-page/group-drill-in.tsx +219 -0
- package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
- package/components/detail-page/hover-highlight-overlay.tsx +111 -0
- package/components/detail-page/hovered-layer.ts +34 -0
- package/components/detail-page/inspector-controls.tsx +175 -0
- package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
- package/components/detail-page/nested-selection-overlay.tsx +364 -0
- package/components/detail-page/panel-search-input.tsx +49 -0
- package/components/detail-page/prompt-edit-panel.tsx +146 -0
- package/components/detail-page/section-height-handle.tsx +276 -0
- package/components/detail-page/section-reauthor-controller.tsx +210 -0
- package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
- package/components/detail-page/spec-resize-absorber.tsx +149 -0
- package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
- package/components/detail-page/table-canvas-overlay.tsx +707 -0
- package/components/detail-page/table-inspector.tsx +586 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/command.tsx +175 -0
- package/components/ui/dialog.tsx +167 -0
- package/components/ui/label.tsx +24 -0
- package/components/ui/select.tsx +190 -0
- package/components/ui/slider.tsx +63 -0
- package/components/ui/switch.tsx +35 -0
- package/components/ui/tooltip.tsx +57 -0
- package/config/detail-page-bundle-font-tags.json +39 -0
- package/config/detail-page-fonts.json +373 -0
- package/index.ts +24 -0
- package/lib/cardnews/font-loader.ts +306 -0
- package/lib/detail-page/animation-sniff.ts +101 -0
- package/lib/detail-page/asset-bytes-url.ts +70 -0
- package/lib/detail-page/basic-shapes.ts +603 -0
- package/lib/detail-page/canvas-menu.ts +182 -0
- package/lib/detail-page/chart/defaults.ts +283 -0
- package/lib/detail-page/chart/format.ts +66 -0
- package/lib/detail-page/chart/normalize.ts +149 -0
- package/lib/detail-page/chart/palette.ts +85 -0
- package/lib/detail-page/chart/paste.ts +82 -0
- package/lib/detail-page/chart/preview.ts +78 -0
- package/lib/detail-page/chart/render.ts +188 -0
- package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
- package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
- package/lib/detail-page/chart/renderers/donut.ts +186 -0
- package/lib/detail-page/chart/renderers/gauge.ts +96 -0
- package/lib/detail-page/chart/renderers/line.ts +143 -0
- package/lib/detail-page/chart/renderers/shared.ts +263 -0
- package/lib/detail-page/chart/renderers/stack.ts +121 -0
- package/lib/detail-page/chart/sync.ts +92 -0
- package/lib/detail-page/chart/types.ts +145 -0
- package/lib/detail-page/css-color.ts +34 -0
- package/lib/detail-page/data-gif-payload.ts +167 -0
- package/lib/detail-page/design-reference.ts +244 -0
- package/lib/detail-page/distribute.ts +89 -0
- package/lib/detail-page/editor-routes.ts +10 -0
- package/lib/detail-page/element-recents.ts +167 -0
- package/lib/detail-page/find-replace.ts +120 -0
- package/lib/detail-page/format-painter.ts +159 -0
- package/lib/detail-page/gif-frames.ts +216 -0
- package/lib/detail-page/gif-timeline.ts +65 -0
- package/lib/detail-page/group-action.ts +62 -0
- package/lib/detail-page/hangul-roman.ts +58 -0
- package/lib/detail-page/icon-curated.ts +290 -0
- package/lib/detail-page/icon-fuzzy.ts +250 -0
- package/lib/detail-page/icon-keywords.generated.ts +3766 -0
- package/lib/detail-page/icon-keywords.ko.ts +524 -0
- package/lib/detail-page/icon-search.ts +257 -0
- package/lib/detail-page/icons.ts +67 -0
- package/lib/detail-page/image-credit.ts +121 -0
- package/lib/detail-page/image-data-uri.ts +29 -0
- package/lib/detail-page/insert-image.ts +75 -0
- package/lib/detail-page/insert-shape.ts +49 -0
- package/lib/detail-page/korean-dictionary.ts +187 -0
- package/lib/detail-page/layer-move.ts +257 -0
- package/lib/detail-page/new-product-draft-store.ts +105 -0
- package/lib/detail-page/new-product-draft.ts +283 -0
- package/lib/detail-page/personal-images-refresh.ts +30 -0
- package/lib/detail-page/product-source.ts +587 -0
- package/lib/detail-page/qr-code.ts +214 -0
- package/lib/detail-page/reference-image.ts +103 -0
- package/lib/detail-page/reference-upload.ts +181 -0
- package/lib/detail-page/replace-page.ts +35 -0
- package/lib/detail-page/replace-with-gif.ts +251 -0
- package/lib/detail-page/section-height.ts +131 -0
- package/lib/detail-page/section-reauthor-bus.ts +61 -0
- package/lib/detail-page/shape-to-image.ts +219 -0
- package/lib/detail-page/spec-group/clipboard.ts +35 -0
- package/lib/detail-page/spec-group/sync.ts +377 -0
- package/lib/detail-page/spec-group/text-metrics.ts +48 -0
- package/lib/detail-page/stock-photos.ts +88 -0
- package/lib/detail-page/svg-colors.ts +74 -0
- package/lib/detail-page/table/defaults.ts +344 -0
- package/lib/detail-page/table/edit.ts +312 -0
- package/lib/detail-page/table/harvest.ts +103 -0
- package/lib/detail-page/table/normalize.ts +125 -0
- package/lib/detail-page/table/paste.ts +49 -0
- package/lib/detail-page/table/preview.ts +66 -0
- package/lib/detail-page/table/render.ts +388 -0
- package/lib/detail-page/table/sync.ts +107 -0
- package/lib/detail-page/table/types.ts +102 -0
- package/lib/detail-page/text-gif-layout.ts +245 -0
- package/lib/detail-page/text-gif-payload.ts +67 -0
- package/lib/detail-page/text-presets.ts +230 -0
- package/lib/detail-page/use-fake-progress.ts +67 -0
- package/lib/detail-page/z-order.ts +82 -0
- package/lib/detail-page-canvas/bubble-path.ts +190 -0
- package/lib/detail-page-canvas/canvas-key.ts +22 -0
- package/lib/detail-page-canvas/clip-rect.ts +65 -0
- package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
- package/lib/detail-page-canvas/editor-fonts.ts +161 -0
- package/lib/detail-page-canvas/export/ai.ts +413 -0
- package/lib/detail-page-canvas/export/color.ts +35 -0
- package/lib/detail-page-canvas/export/document-model.ts +78 -0
- package/lib/detail-page-canvas/export/export-files.ts +290 -0
- package/lib/detail-page-canvas/export/font-subset.ts +264 -0
- package/lib/detail-page-canvas/export/gif-export.ts +310 -0
- package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
- package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
- package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
- package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
- package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
- package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
- package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
- package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
- package/lib/detail-page-canvas/export/psd.ts +354 -0
- package/lib/detail-page-canvas/export/raster.ts +255 -0
- package/lib/detail-page-canvas/export/svg.ts +479 -0
- package/lib/detail-page-canvas/export/text-layout.ts +142 -0
- package/lib/detail-page-canvas/font-catalog.ts +177 -0
- package/lib/detail-page-canvas/font-tags.ts +47 -0
- package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
- package/lib/detail-page-canvas/konva-fallback.ts +477 -0
- package/lib/detail-page-canvas/text-background-plan.ts +124 -0
- package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
- package/lib/utils.ts +10 -0
- package/package.json +68 -0
- package/types/commerce.ts +171 -0
- package/types/detail-page-api.ts +200 -0
- package/types/detail-page-canvas.ts +20 -0
- package/types/fontkit.d.ts +42 -0
- 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";
|