@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,261 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// 브랜드 GIF 패널 — 브랜드 이미지에서 GIF만 떼어내 만든 경로별로 나눠 보여준다.
|
|
4
|
+
//
|
|
5
|
+
// 사진과 GIF가 한 그리드에 섞여 있으면 고르기 어렵다(썸네일만으로는 구분도 안 된다).
|
|
6
|
+
// 목록은 서버가 ``media=gif``로 걸러 주고, 각 항목의 구획(``gif_kind``)도 서버가
|
|
7
|
+
// 붙여 준다 — 결과 GIF 바이트만 봐서는 텍스트에서 구웠는지 도형에서 구웠는지 알 수
|
|
8
|
+
// 없기 때문에, 만든 시점의 태그가 유일한 근거다.
|
|
9
|
+
//
|
|
10
|
+
// 분류가 없는 GIF(유저가 직접 올린 것 등)는 '기타'로 모은다. 4구획 중 하나로
|
|
11
|
+
// 억지로 밀어 넣으면 없는 사실을 만드는 셈이고, 빼 버리면 올린 GIF가 사라진다.
|
|
12
|
+
|
|
13
|
+
import { useRef, useState, type ChangeEvent } from "react";
|
|
14
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
15
|
+
import { Film, Loader2, Trash2, Upload } from "lucide-react";
|
|
16
|
+
import { useTranslation } from "react-i18next";
|
|
17
|
+
|
|
18
|
+
import { BrandPanelHeader } from "./detail-page-brand-panel-header";
|
|
19
|
+
import { useDetailPageHost } from "./detail-page-host-context";
|
|
20
|
+
import type {
|
|
21
|
+
BrandAsset,
|
|
22
|
+
BrandAssetGifKind,
|
|
23
|
+
} from "./detail-page-host-context";
|
|
24
|
+
import { insertPersonalImage } from "../../lib/detail-page/insert-image";
|
|
25
|
+
|
|
26
|
+
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
/** 구획 순서 = 화면 순서. 텍스트 → 이미지(이펙트/프롬프트) → 도형. */
|
|
29
|
+
const SECTIONS: Array<{
|
|
30
|
+
kind: BrandAssetGifKind;
|
|
31
|
+
labelKey: string;
|
|
32
|
+
fallback: string;
|
|
33
|
+
}> = [
|
|
34
|
+
{
|
|
35
|
+
kind: "text",
|
|
36
|
+
labelKey: "detailPage.brandGifs.sectionText",
|
|
37
|
+
fallback: "텍스트 GIF",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
kind: "image_effect",
|
|
41
|
+
labelKey: "detailPage.brandGifs.sectionImageEffect",
|
|
42
|
+
fallback: "이미지 GIF (이펙트)",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
kind: "image_prompt",
|
|
46
|
+
labelKey: "detailPage.brandGifs.sectionImagePrompt",
|
|
47
|
+
fallback: "이미지 GIF (프롬프트)",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
kind: "shape",
|
|
51
|
+
labelKey: "detailPage.brandGifs.sectionShape",
|
|
52
|
+
fallback: "도형 GIF",
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* GIF들을 화면 구획으로 나눈다. 분류가 없는 것은 마지막 '기타' 묶음으로 간다.
|
|
58
|
+
*
|
|
59
|
+
* 반환은 **비어 있지 않은 구획만** — 빈 제목만 네 줄 떠 있으면 패널이 고장 난 것처럼
|
|
60
|
+
* 보인다.
|
|
61
|
+
*/
|
|
62
|
+
export function groupBrandGifs(assets: BrandAsset[]): Array<{
|
|
63
|
+
kind: BrandAssetGifKind | "other";
|
|
64
|
+
items: BrandAsset[];
|
|
65
|
+
}> {
|
|
66
|
+
const buckets = new Map<BrandAssetGifKind | "other", BrandAsset[]>();
|
|
67
|
+
for (const asset of assets) {
|
|
68
|
+
const key = asset.gif_kind ?? "other";
|
|
69
|
+
const bucket = buckets.get(key);
|
|
70
|
+
if (bucket) bucket.push(asset);
|
|
71
|
+
else buckets.set(key, [asset]);
|
|
72
|
+
}
|
|
73
|
+
const ordered: Array<BrandAssetGifKind | "other"> = [
|
|
74
|
+
...SECTIONS.map((section) => section.kind),
|
|
75
|
+
"other",
|
|
76
|
+
];
|
|
77
|
+
return ordered
|
|
78
|
+
.map((kind) => ({ kind, items: buckets.get(kind) ?? [] }))
|
|
79
|
+
.filter((section) => section.items.length > 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function DetailPageBrandGifsPanel({ store }: { store: unknown }) {
|
|
83
|
+
const { brand, queryKeys, toast } = useDetailPageHost();
|
|
84
|
+
const { t } = useTranslation("branding");
|
|
85
|
+
const queryClient = useQueryClient();
|
|
86
|
+
const {
|
|
87
|
+
activeBrand,
|
|
88
|
+
activeBrandId,
|
|
89
|
+
isLoading: brandsLoading,
|
|
90
|
+
} = brand.useBrandWorkspace();
|
|
91
|
+
const fileRef = useRef<HTMLInputElement>(null);
|
|
92
|
+
const [error, setError] = useState<string | null>(null);
|
|
93
|
+
|
|
94
|
+
const gifsQuery = useQuery({
|
|
95
|
+
queryKey: queryKeys.branding.brandAssets(activeBrandId, "gif"),
|
|
96
|
+
queryFn: ({ signal }) => brand.listBrandAssets(activeBrandId!, signal, "gif"),
|
|
97
|
+
enabled: Boolean(activeBrandId),
|
|
98
|
+
staleTime: 4 * 60_000,
|
|
99
|
+
});
|
|
100
|
+
const sections = groupBrandGifs(gifsQuery.data ?? []);
|
|
101
|
+
|
|
102
|
+
const invalidate = () =>
|
|
103
|
+
queryClient.invalidateQueries({
|
|
104
|
+
queryKey: queryKeys.branding.brandAssets(activeBrandId),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const uploadMutation = useMutation({
|
|
108
|
+
mutationFn: async (file: File) => {
|
|
109
|
+
if (!activeBrandId) throw new Error("brand-not-selected");
|
|
110
|
+
return brand.uploadBrandAsset(activeBrandId, file, "gif", {
|
|
111
|
+
metadata: { source: "canvas_upload" },
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
onSuccess: async () => {
|
|
115
|
+
setError(null);
|
|
116
|
+
await invalidate();
|
|
117
|
+
},
|
|
118
|
+
onError: () => setError(t("detailPage.brandAssets.uploadFailed")),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const deleteMutation = useMutation({
|
|
122
|
+
mutationFn: (asset: BrandAsset) => brand.deleteBrandAsset(asset),
|
|
123
|
+
onSuccess: invalidate,
|
|
124
|
+
onError: () => toast.error(t("detailPage.brandAssets.deleteFailed")),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const handleUpload = (event: ChangeEvent<HTMLInputElement>) => {
|
|
128
|
+
const file = event.target.files?.[0];
|
|
129
|
+
event.target.value = "";
|
|
130
|
+
if (!file) return;
|
|
131
|
+
if (file.type !== "image/gif") {
|
|
132
|
+
setError(
|
|
133
|
+
t("detailPage.brandGifs.notAGif", {
|
|
134
|
+
defaultValue: "GIF 파일만 올릴 수 있어요.",
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
140
|
+
setError(t("detailPage.brandAssets.uploadTooLarge"));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
uploadMutation.mutate(file);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className="flex h-full flex-col p-4">
|
|
148
|
+
<BrandPanelHeader onRefresh={() => void gifsQuery.refetch()} />
|
|
149
|
+
|
|
150
|
+
<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">
|
|
151
|
+
{uploadMutation.isPending ? (
|
|
152
|
+
<Loader2 aria-hidden="true" className="animate-spin" size={18} />
|
|
153
|
+
) : (
|
|
154
|
+
<Upload aria-hidden="true" size={18} />
|
|
155
|
+
)}
|
|
156
|
+
<span className="text-[11px]">
|
|
157
|
+
{t("detailPage.brandGifs.upload", { defaultValue: "GIF 올리기" })}
|
|
158
|
+
</span>
|
|
159
|
+
<input
|
|
160
|
+
ref={fileRef}
|
|
161
|
+
type="file"
|
|
162
|
+
accept="image/gif"
|
|
163
|
+
className="hidden"
|
|
164
|
+
onChange={handleUpload}
|
|
165
|
+
/>
|
|
166
|
+
</label>
|
|
167
|
+
|
|
168
|
+
{brandsLoading || gifsQuery.isLoading ? (
|
|
169
|
+
<div className="flex flex-1 items-center justify-center text-neutral-400">
|
|
170
|
+
<Loader2 aria-hidden="true" className="animate-spin" size={22} />
|
|
171
|
+
</div>
|
|
172
|
+
) : error || gifsQuery.error ? (
|
|
173
|
+
<p className="text-xs font-medium text-red-600">
|
|
174
|
+
{error ?? t("detailPage.brandAssets.loadFailed")}
|
|
175
|
+
</p>
|
|
176
|
+
) : !activeBrand ? (
|
|
177
|
+
<p className="text-xs text-neutral-400">
|
|
178
|
+
{t("detailPage.brandAssets.noBrand")}
|
|
179
|
+
</p>
|
|
180
|
+
) : sections.length === 0 ? (
|
|
181
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-neutral-400">
|
|
182
|
+
<Film aria-hidden="true" size={22} />
|
|
183
|
+
<p className="text-xs">
|
|
184
|
+
{t("detailPage.brandGifs.empty", {
|
|
185
|
+
defaultValue:
|
|
186
|
+
"아직 GIF가 없어요. 텍스트·이미지·도형을 골라 GIF로 만들어 보세요.",
|
|
187
|
+
})}
|
|
188
|
+
</p>
|
|
189
|
+
</div>
|
|
190
|
+
) : (
|
|
191
|
+
<div className="flex flex-col gap-4 overflow-y-auto">
|
|
192
|
+
{sections.map((section) => (
|
|
193
|
+
<section key={section.kind}>
|
|
194
|
+
<h3 className="mb-1.5 text-[11px] font-semibold text-neutral-500">
|
|
195
|
+
{section.kind === "other"
|
|
196
|
+
? t("detailPage.brandGifs.sectionOther", {
|
|
197
|
+
defaultValue: "기타",
|
|
198
|
+
})
|
|
199
|
+
: t(
|
|
200
|
+
SECTIONS.find((entry) => entry.kind === section.kind)!
|
|
201
|
+
.labelKey,
|
|
202
|
+
{
|
|
203
|
+
defaultValue: SECTIONS.find(
|
|
204
|
+
(entry) => entry.kind === section.kind,
|
|
205
|
+
)!.fallback,
|
|
206
|
+
},
|
|
207
|
+
)}
|
|
208
|
+
<span className="ml-1 font-normal text-neutral-300">
|
|
209
|
+
{section.items.length}
|
|
210
|
+
</span>
|
|
211
|
+
</h3>
|
|
212
|
+
<div className="grid grid-cols-2 gap-2">
|
|
213
|
+
{section.items.map((asset) => (
|
|
214
|
+
<div
|
|
215
|
+
key={asset.id}
|
|
216
|
+
className="group relative overflow-hidden rounded-md border border-neutral-200 hover:border-neutral-500"
|
|
217
|
+
>
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
disabled={!asset.download_url}
|
|
221
|
+
onClick={() =>
|
|
222
|
+
// 문서에 박히는 주소는 만료 없는 경로여야 한다 — presigned를
|
|
223
|
+
// 박으면 저장하고 몇 분 뒤 다시 열 때 403으로 깨진다.
|
|
224
|
+
asset.download_url &&
|
|
225
|
+
insertPersonalImage(store, brand.brandAssetDocumentSrc(asset), {
|
|
226
|
+
isGif: true,
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
className="block w-full disabled:opacity-50"
|
|
230
|
+
title={t("detailPage.brandAssets.insertHint")}
|
|
231
|
+
>
|
|
232
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
233
|
+
<img
|
|
234
|
+
src={asset.download_url ?? ""}
|
|
235
|
+
crossOrigin="anonymous"
|
|
236
|
+
alt={asset.display_name ?? asset.filename}
|
|
237
|
+
className="h-24 w-full object-cover transition group-hover:opacity-90"
|
|
238
|
+
/>
|
|
239
|
+
</button>
|
|
240
|
+
<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">
|
|
241
|
+
{String(asset.metadata?.effect ?? "") ||
|
|
242
|
+
(asset.display_name ?? asset.filename)}
|
|
243
|
+
</span>
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
onClick={() => deleteMutation.mutate(asset)}
|
|
247
|
+
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"
|
|
248
|
+
aria-label={t("detailPage.brandAssets.delete")}
|
|
249
|
+
>
|
|
250
|
+
<Trash2 aria-hidden="true" size={13} />
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
))}
|
|
254
|
+
</div>
|
|
255
|
+
</section>
|
|
256
|
+
))}
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { Loader2, Palette } from "lucide-react";
|
|
5
|
+
import { useTranslation } from "react-i18next";
|
|
6
|
+
|
|
7
|
+
import { useDetailPageHost } from "./detail-page-host-context";
|
|
8
|
+
|
|
9
|
+
export function DetailPageBrandKitPanel({ store }: { store: unknown }) {
|
|
10
|
+
// 킷을 그리는 화면은 브랜드 도메인이라 호스트가 꽂는다. 편집기는 활성 브랜드의
|
|
11
|
+
// 무드보드를 불러 킷으로 바꾼 뒤 그 자리에 넘길 뿐이다.
|
|
12
|
+
const { brand, queryKeys, slots } = useDetailPageHost();
|
|
13
|
+
const { t } = useTranslation("branding");
|
|
14
|
+
const {
|
|
15
|
+
brands,
|
|
16
|
+
activeBrand,
|
|
17
|
+
activeBrandId,
|
|
18
|
+
setActiveBrandId,
|
|
19
|
+
isLoading: brandsLoading,
|
|
20
|
+
} = brand.useBrandWorkspace();
|
|
21
|
+
const assetsQuery = useQuery({
|
|
22
|
+
queryKey: queryKeys.branding.brandAssets(activeBrandId),
|
|
23
|
+
queryFn: ({ signal }) => brand.listBrandAssets(activeBrandId!, signal),
|
|
24
|
+
enabled: Boolean(activeBrandId),
|
|
25
|
+
staleTime: 4 * 60_000,
|
|
26
|
+
});
|
|
27
|
+
const moodboardQuery = useQuery({
|
|
28
|
+
queryKey: queryKeys.branding.brandMoodboard(activeBrandId),
|
|
29
|
+
queryFn: ({ signal }) =>
|
|
30
|
+
brand.loadBrandMoodboard(activeBrand!, assetsQuery.data ?? [], signal),
|
|
31
|
+
enabled: Boolean(activeBrand && assetsQuery.data),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (brandsLoading || assetsQuery.isLoading || moodboardQuery.isLoading) {
|
|
35
|
+
return (
|
|
36
|
+
<div className="flex h-full items-center justify-center text-neutral-400">
|
|
37
|
+
<Loader2 className="animate-spin" size={20} />
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const KitPanel = slots?.BrandKitPanel;
|
|
43
|
+
if (!activeBrand || !moodboardQuery.data || !KitPanel) {
|
|
44
|
+
return (
|
|
45
|
+
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-neutral-400">
|
|
46
|
+
<Palette size={22} />
|
|
47
|
+
<p className="text-xs">{t("detailPage.brandAssets.kitEmpty")}</p>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="flex h-full flex-col overflow-y-auto">
|
|
54
|
+
<div className="border-b border-neutral-200 p-3">
|
|
55
|
+
<label className="grid gap-1 text-[11px] font-medium text-neutral-500">
|
|
56
|
+
{t("detailPage.brandAssets.activeBrand")}
|
|
57
|
+
<select
|
|
58
|
+
value={activeBrand.id}
|
|
59
|
+
onChange={(event) => setActiveBrandId(event.target.value)}
|
|
60
|
+
className="h-8 rounded-md border border-neutral-200 bg-white px-2 text-xs text-neutral-800"
|
|
61
|
+
>
|
|
62
|
+
{brands.map((brand) => (
|
|
63
|
+
<option key={brand.id} value={brand.id}>
|
|
64
|
+
{brand.name}
|
|
65
|
+
</option>
|
|
66
|
+
))}
|
|
67
|
+
</select>
|
|
68
|
+
</label>
|
|
69
|
+
</div>
|
|
70
|
+
<KitPanel
|
|
71
|
+
kit={brand.deriveBrandKit(moodboardQuery.data)}
|
|
72
|
+
store={store as never}
|
|
73
|
+
className="flex-1"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// 브랜드 자산 패널들(이미지 · GIF · 도형)이 공유하는 머리글.
|
|
4
|
+
//
|
|
5
|
+
// 어느 브랜드의 자산을 보고 있는지 고르는 셀렉트 + 새로고침. 패널마다 같은 마크업을
|
|
6
|
+
// 복사해 두면 한쪽만 고쳐져 "이 패널에서만 브랜드가 안 바뀐다"가 생긴다.
|
|
7
|
+
|
|
8
|
+
import { RefreshCw } from "lucide-react";
|
|
9
|
+
import { useTranslation } from "react-i18next";
|
|
10
|
+
|
|
11
|
+
import { useDetailPageHost } from "./detail-page-host-context";
|
|
12
|
+
|
|
13
|
+
export function BrandPanelHeader({ onRefresh }: { onRefresh: () => void }) {
|
|
14
|
+
const { brand } = useDetailPageHost();
|
|
15
|
+
const { t } = useTranslation("branding");
|
|
16
|
+
const { brands, activeBrandId, setActiveBrandId } = brand.useBrandWorkspace();
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="mb-3 flex items-center justify-between gap-2">
|
|
20
|
+
<label className="grid min-w-0 flex-1 gap-1 text-[11px] font-medium text-neutral-500">
|
|
21
|
+
{t("detailPage.brandAssets.activeBrand")}
|
|
22
|
+
<select
|
|
23
|
+
value={activeBrandId ?? ""}
|
|
24
|
+
onChange={(event) => setActiveBrandId(event.target.value)}
|
|
25
|
+
className="h-8 min-w-0 rounded-md border border-neutral-200 bg-white px-2 text-xs text-neutral-800"
|
|
26
|
+
>
|
|
27
|
+
{brands.map((brand) => (
|
|
28
|
+
<option key={brand.id} value={brand.id}>
|
|
29
|
+
{brand.name}
|
|
30
|
+
</option>
|
|
31
|
+
))}
|
|
32
|
+
</select>
|
|
33
|
+
</label>
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
onClick={onRefresh}
|
|
37
|
+
className="mt-4 flex h-7 w-7 items-center justify-center rounded-md text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
|
|
38
|
+
aria-label={t("detailPage.brandAssets.refresh")}
|
|
39
|
+
>
|
|
40
|
+
<RefreshCw aria-hidden="true" size={14} />
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useMemo } from "react";
|
|
4
|
+
import { useTranslation } from "react-i18next";
|
|
5
|
+
|
|
6
|
+
import { CHART_PRESETS, createChartSpec, type ChartPreset } from "../../lib/detail-page/chart/defaults";
|
|
7
|
+
import { chartPreviewSvg } from "../../lib/detail-page/chart/preview";
|
|
8
|
+
import { scaleChartStyle } from "../../lib/detail-page/chart/render";
|
|
9
|
+
import { documentFontFamily, insertChart, type StoreLike } from "../../lib/detail-page/chart/sync";
|
|
10
|
+
import { encodeSvgDataUri } from "../../lib/detail-page-canvas/export/svg";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* "차트" 패널 — 프리셋을 클릭하면 캔버스에 차트가 놓인다.
|
|
14
|
+
*
|
|
15
|
+
* 썸네일은 별도 자산이 아니라 **실제 렌더러 출력**을 SVG로 옮긴 것이다(``preview.ts``).
|
|
16
|
+
* 프리셋을 고치면 썸네일이 저절로 따라오고, 카탈로그와 결과가 어긋나지 않는다.
|
|
17
|
+
*
|
|
18
|
+
* 넣는 차트는 페이지 폭의 80%, 폰트는 문서에서 가장 많이 쓰인 것을 물려받는다 —
|
|
19
|
+
* 캔버스에 놓자마자 페이지 톤에 붙어 있어야 "AI가 얹어 준 것"처럼 보이지 않는다.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** 썸네일 기준 폭. 실제 삽입 폭(≈600)과의 비율로 스타일을 줄인다. */
|
|
23
|
+
const PREVIEW_WIDTH = 220;
|
|
24
|
+
const REFERENCE_WIDTH = 600;
|
|
25
|
+
|
|
26
|
+
const PREVIEW_DATA = {
|
|
27
|
+
labels: ["우리", "A", "B"],
|
|
28
|
+
series: [{ name: "값", values: [92, 61, 45] }],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function previewUri(preset: ChartPreset): string {
|
|
32
|
+
const full = createChartSpec({
|
|
33
|
+
kind: preset.kind,
|
|
34
|
+
width: PREVIEW_WIDTH,
|
|
35
|
+
style: preset.style,
|
|
36
|
+
options: preset.options,
|
|
37
|
+
data: preset.data ?? PREVIEW_DATA,
|
|
38
|
+
});
|
|
39
|
+
return encodeSvgDataUri(
|
|
40
|
+
chartPreviewSvg(scaleChartStyle(full, PREVIEW_WIDTH / REFERENCE_WIDTH)),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function DetailPageChartsPanel({ store }: { store: unknown }) {
|
|
45
|
+
const { t } = useTranslation("branding");
|
|
46
|
+
const s = store as StoreLike;
|
|
47
|
+
|
|
48
|
+
// 프리셋은 코드 상수라 세션 내내 안 바뀐다. 매 렌더마다 SVG를 다시 굽지 않는다.
|
|
49
|
+
const cells = useMemo(
|
|
50
|
+
() => CHART_PRESETS.map((preset) => ({ preset, uri: previewUri(preset) })),
|
|
51
|
+
[],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const insert = useCallback(
|
|
55
|
+
(preset: ChartPreset) => {
|
|
56
|
+
const page = s.activePage ?? s.pages?.[0];
|
|
57
|
+
const pageWidth =
|
|
58
|
+
typeof page?.computedWidth === "number" ? page.computedWidth : 860;
|
|
59
|
+
insertChart(
|
|
60
|
+
s,
|
|
61
|
+
createChartSpec({
|
|
62
|
+
kind: preset.kind,
|
|
63
|
+
width: Math.round(pageWidth * 0.8),
|
|
64
|
+
fontFamily: documentFontFamily(s),
|
|
65
|
+
style: preset.style,
|
|
66
|
+
options: preset.options,
|
|
67
|
+
data: preset.data,
|
|
68
|
+
}),
|
|
69
|
+
{ name: t(preset.labelKey) },
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
[s, t],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="flex h-full flex-col overflow-y-auto p-3">
|
|
77
|
+
<p className="mb-2 text-xs font-medium text-neutral-500">
|
|
78
|
+
{t("detailPage.chart.panelTitle")}
|
|
79
|
+
</p>
|
|
80
|
+
<div className="grid grid-cols-2 gap-2">
|
|
81
|
+
{cells.map(({ preset, uri }) => (
|
|
82
|
+
<button
|
|
83
|
+
key={preset.id}
|
|
84
|
+
type="button"
|
|
85
|
+
onClick={() => insert(preset)}
|
|
86
|
+
title={t("detailPage.chart.insertHint")}
|
|
87
|
+
className="flex flex-col items-center gap-1.5 rounded-lg border border-neutral-200 p-2 hover:border-neutral-400"
|
|
88
|
+
>
|
|
89
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
90
|
+
<img
|
|
91
|
+
src={uri}
|
|
92
|
+
alt={t(preset.labelKey)}
|
|
93
|
+
className="h-20 w-full object-contain"
|
|
94
|
+
/>
|
|
95
|
+
<span className="text-[11px] text-neutral-500">{t(preset.labelKey)}</span>
|
|
96
|
+
</button>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
<p className="mt-3 text-[11px] leading-relaxed text-neutral-400">
|
|
100
|
+
{t("detailPage.chart.panelHint")}
|
|
101
|
+
</p>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useMemo } from "react";
|
|
4
|
+
import { useQuery } from "@tanstack/react-query";
|
|
5
|
+
import { Loader2 } from "lucide-react";
|
|
6
|
+
import { useTranslation } from "react-i18next";
|
|
7
|
+
|
|
8
|
+
import { useDetailPageHost } from "./detail-page-host-context";
|
|
9
|
+
import type { DetailPageShapeLibraryItem } from "./detail-page-host-context";
|
|
10
|
+
import { encodeSvgDataUri } from "../../lib/detail-page-canvas/export/svg";
|
|
11
|
+
import { insertShape } from "../../lib/detail-page/insert-shape";
|
|
12
|
+
import { rememberElement } from "../../lib/detail-page/element-recents";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* "장식" 패널 — 우리 템플릿에서 추린 **공용 장식 카탈로그**(읽기전용, 클릭 삽입).
|
|
16
|
+
*
|
|
17
|
+
* 카탈로그는 서버가 세 갈래로 나눠 준다: `icon` · `badge` · `line`.
|
|
18
|
+
* 여기서는 **`icon`을 안 쓴다.** 그건 루시드류 단색 픽토그램이고, "아이콘" 그룹이 같은
|
|
19
|
+
* 것을 2만 개 규모로 검색까지 붙여 덮는다. 36개짜리 사본을 옆에 두면 사용자는 어느 쪽을
|
|
20
|
+
* 봐야 하는지부터 고민하게 된다.
|
|
21
|
+
*
|
|
22
|
+
* 남는 `badge`(배지 바탕·씰·말풍선)와 `line`(선 장식·구분)이 이 패널의 전부다.
|
|
23
|
+
* 개인 도형("내 도형")과는 별개다.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** 공용 카탈로그에서 "장식"으로 볼 갈래. */
|
|
27
|
+
const DECORATION_CATEGORIES = ["badge", "line"] as const;
|
|
28
|
+
|
|
29
|
+
export function DetailPageDecorationsPanel({ store }: { store: unknown }) {
|
|
30
|
+
const { api, queryKeys } = useDetailPageHost();
|
|
31
|
+
const { t } = useTranslation("branding");
|
|
32
|
+
// 카탈로그는 리포에 커밋된 정적 파일이라 배포 사이엔 바뀌지 않는다. 패널은 그룹을
|
|
33
|
+
// 옮길 때마다 언마운트되므로, 컴포넌트 지역 상태로 들고 있으면 열 때마다 1.7MB를
|
|
34
|
+
// 다시 받는다. 쿼리 캐시에 얹어 세션당 한 번만 받는다(서버 ETag/304는 새로고침용).
|
|
35
|
+
const {
|
|
36
|
+
data: items = [],
|
|
37
|
+
isPending,
|
|
38
|
+
isError,
|
|
39
|
+
} = useQuery({
|
|
40
|
+
queryKey: queryKeys.branding.detailPageShapeLibrary(),
|
|
41
|
+
queryFn: ({ signal }) => api.listDetailPageShapeLibrary(signal),
|
|
42
|
+
staleTime: Infinity,
|
|
43
|
+
gcTime: Infinity,
|
|
44
|
+
refetchOnWindowFocus: false,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// 수십 KB SVG를 매 렌더마다 다시 인코딩하지 않는다.
|
|
48
|
+
const groups = useMemo(
|
|
49
|
+
() =>
|
|
50
|
+
DECORATION_CATEGORIES.map((category) => ({
|
|
51
|
+
category,
|
|
52
|
+
cells: items
|
|
53
|
+
.filter((item) => item.category === category)
|
|
54
|
+
.map((item) => ({ item, uri: encodeSvgDataUri(item.svg) })),
|
|
55
|
+
})).filter((group) => group.cells.length > 0),
|
|
56
|
+
[items],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const insert = useCallback(
|
|
60
|
+
(item: DetailPageShapeLibraryItem) => {
|
|
61
|
+
insertShape(store, item.svg, item.view_box);
|
|
62
|
+
rememberElement({
|
|
63
|
+
key: item.id,
|
|
64
|
+
markup: item.svg,
|
|
65
|
+
viewBox: item.view_box,
|
|
66
|
+
label: t(`detailPage.shapes.decorations.${item.category}`),
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
[store, t],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (isError) {
|
|
73
|
+
return (
|
|
74
|
+
<div className="p-3">
|
|
75
|
+
<p className="text-xs text-red-600">{t("detailPage.shapes.decorationsFailed")}</p>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (isPending) {
|
|
81
|
+
return (
|
|
82
|
+
<div className="flex h-full items-center justify-center text-neutral-400">
|
|
83
|
+
<Loader2 aria-hidden="true" className="animate-spin" size={18} />
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!groups.length) {
|
|
89
|
+
return (
|
|
90
|
+
<div className="p-3">
|
|
91
|
+
<p className="text-xs text-neutral-400">{t("detailPage.shapes.decorationsEmpty")}</p>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="flex h-full flex-col overflow-y-auto p-3">
|
|
98
|
+
{groups.map(({ category, cells }) => (
|
|
99
|
+
<div key={category} className="mb-4 last:mb-0">
|
|
100
|
+
<p className="mb-2 text-xs font-medium text-neutral-500">
|
|
101
|
+
{t(`detailPage.shapes.decorations.${category}`)}
|
|
102
|
+
</p>
|
|
103
|
+
<div className="grid grid-cols-4 gap-2">
|
|
104
|
+
{cells.map(({ item, uri }) => (
|
|
105
|
+
<button
|
|
106
|
+
key={item.id}
|
|
107
|
+
type="button"
|
|
108
|
+
onClick={() => insert(item)}
|
|
109
|
+
className="flex aspect-square items-center justify-center rounded-lg border border-neutral-200 p-2 hover:border-neutral-400"
|
|
110
|
+
title={t("detailPage.shapes.insertHint")}
|
|
111
|
+
>
|
|
112
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
113
|
+
<img
|
|
114
|
+
src={uri}
|
|
115
|
+
alt={t("detailPage.shapes.shapeAlt")}
|
|
116
|
+
className="max-h-full max-w-full object-contain"
|
|
117
|
+
/>
|
|
118
|
+
</button>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|