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