@leviosa-ai/detail-page-editor 0.9.0 → 0.11.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.
@@ -1,20 +1,12 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useMemo, useState, type ReactNode } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
4
4
  import { useTranslation } from "react-i18next";
5
5
  import { EditorHotkeys } from "./editor-hotkeys";
6
+ import { useAutoSave, type SaveReason } from "./use-auto-save";
6
7
  import { FindReplacePanel } from "./find-replace-panel";
7
8
 
8
- import {
9
- ChevronLeft,
10
- Copy,
11
- Download,
12
- Eye,
13
- EyeOff,
14
- ListChecks,
15
- RefreshCw,
16
- Save,
17
- } from "lucide-react";
9
+ import { ChevronLeft, Save } from "lucide-react";
18
10
 
19
11
  import {
20
12
  applyTextLineFit,
@@ -49,16 +41,28 @@ import type {
49
41
  GenerateDataGifFn,
50
42
  RemoveBackgroundFn,
51
43
  } from "./ai-generate-panel";
52
- import type {
53
- LeviosaCanvasDocument,
54
- CanvasSlotBinding,
55
- } from "../../types/detail-page-canvas";
44
+ import type { LeviosaCanvasDocument } from "../../types/detail-page-canvas";
56
45
 
57
46
  export type DetailPageEditorProps = {
58
47
  initialDocument: LeviosaCanvasDocument;
59
48
  saving?: boolean;
60
49
  uploadFile?: (file: File) => Promise<string>;
61
- onSave: (document: LeviosaCanvasDocument) => Promise<void>;
50
+ /**
51
+ * 문서를 저장한다. `reason` 은 왜 지금 저장하는가다 — 앱이 이걸 보고 무거운 뒷일
52
+ * (상세페이지의 HTML 굽기 같은)을 할지 말지 고른다. 자동저장은 `auto`, 저장 버튼은
53
+ * `manual`, 탭을 닫거나 편집기를 떠날 때는 `leave` 로 온다.
54
+ */
55
+ onSave: (
56
+ document: LeviosaCanvasDocument,
57
+ meta: { reason: SaveReason },
58
+ ) => Promise<void>;
59
+ /**
60
+ * 자동저장 간격(ms). 없으면 자동저장을 안 한다.
61
+ *
62
+ * 저장이 무거운 화면은 길게 잡는다 — 상세페이지 저장은 서버가 HTML 을 새로 만들어
63
+ * S3 에 올리고, 캐러셀 저장은 문서 JSON 만 넣는다.
64
+ */
65
+ autoSaveDelayMs?: number;
62
66
  /** AI 생성 탭의 프롬프트 → 이미지 URL 콜백. 없으면 패널은 안내만 표시. */
63
67
  onGenerateImage?: GenerateImageFn;
64
68
  /** AI 생성 탭의 프롬프트 → 애니메이션 GIF URL 콜백. 없으면 GIF 모드는 안내만 표시. */
@@ -124,313 +128,12 @@ export type DetailPageEditorProps = {
124
128
 
125
129
  type CanvasJson = Record<string, unknown>;
126
130
 
127
- type CanvasJsonElement = Record<string, unknown> & {
128
- id?: string;
129
- type?: string;
130
- x?: number;
131
- y?: number;
132
- width?: number;
133
- height?: number;
134
- text?: string;
135
- src?: string;
136
- custom?: Record<string, unknown>;
137
- children?: CanvasJsonElement[];
138
- };
139
-
140
- type SlotInspection = {
141
- slot: string;
142
- binding?: CanvasSlotBinding;
143
- element?: CanvasJsonElement;
144
- pageId?: string;
145
- state: "ok" | "warning" | "error";
146
- message: string;
147
- };
148
-
149
- type TemplateLintIssue = {
150
- level: "error" | "warning" | "info";
151
- title: string;
152
- detail: string;
153
- slot?: string;
154
- elementId?: string;
155
- };
156
-
157
- type TemplateQaSnapshot = {
158
- json: CanvasJson;
159
- slots: SlotInspection[];
160
- lint: TemplateLintIssue[];
161
- summary: string;
162
- counts: {
163
- pages: number;
164
- elements: number;
165
- slots: number;
166
- errors: number;
167
- warnings: number;
168
- };
169
- };
170
-
171
- const QA_OVERLAY_CUSTOM_KEY = "leviosaQaOverlay";
172
- const QA_OVERLAY_PREFIX = "leviosa-qa-overlay";
173
-
174
- function asRecord(value: unknown): Record<string, unknown> {
175
- return value && typeof value === "object" && !Array.isArray(value)
176
- ? (value as Record<string, unknown>)
177
- : {};
178
- }
179
-
180
- function asArray(value: unknown): unknown[] {
181
- return Array.isArray(value) ? value : [];
182
- }
183
-
184
- function finiteNumber(value: unknown): number | null {
185
- return typeof value === "number" && Number.isFinite(value) ? value : null;
186
- }
187
-
188
- function isQaOverlayElement(element: CanvasJsonElement): boolean {
189
- return element.id?.startsWith(`${QA_OVERLAY_PREFIX}-`) === true ||
190
- element.custom?.[QA_OVERLAY_CUSTOM_KEY] === true;
191
- }
192
-
193
- function walkElements(
194
- json: CanvasJson,
195
- visitor: (element: CanvasJsonElement, page: Record<string, unknown>) => void,
196
- ) {
197
- for (const page of asArray(json.pages) as Array<Record<string, unknown>>) {
198
- const visit = (element: CanvasJsonElement) => {
199
- visitor(element, page);
200
- for (const child of asArray(element.children) as CanvasJsonElement[]) {
201
- visit(child);
202
- }
203
- };
204
- for (const element of asArray(page.children) as CanvasJsonElement[]) {
205
- visit(element);
206
- }
207
- }
208
- }
209
-
210
- function stripQaOverlayElements(json: CanvasJson): CanvasJson {
211
- const clone = JSON.parse(JSON.stringify(json)) as CanvasJson;
212
- const strip = (elements: CanvasJsonElement[]): CanvasJsonElement[] =>
213
- elements
214
- .filter((element) => !isQaOverlayElement(element))
215
- .map((element) => ({
216
- ...element,
217
- children: Array.isArray(element.children)
218
- ? strip(element.children)
219
- : element.children,
220
- }));
221
-
222
- clone.pages = (asArray(clone.pages) as Array<Record<string, unknown>>).map((page) => ({
223
- ...page,
224
- children: strip(asArray(page.children) as CanvasJsonElement[]),
225
- }));
226
- return clone;
227
- }
228
-
229
- function getSlotFromElement(element: CanvasJsonElement): string {
230
- const custom = asRecord(element.custom);
231
- return typeof custom.leviosaSlot === "string" ? custom.leviosaSlot : "";
232
- }
233
-
234
- function getElementKind(element?: CanvasJsonElement): CanvasSlotBinding["kind"] | null {
235
- if (!element) return null;
236
- if (element.type === "image") return "image";
237
- if (element.type === "text") return "text";
238
- return null;
239
- }
240
-
241
- function formatRect(element?: CanvasJsonElement): string {
242
- if (!element) return "-";
243
- const x = finiteNumber(element.x) ?? 0;
244
- const y = finiteNumber(element.y) ?? 0;
245
- const width = finiteNumber(element.width) ?? 0;
246
- const height = finiteNumber(element.height) ?? 0;
247
- return `${Math.round(x)}, ${Math.round(y)} / ${Math.round(width)} x ${Math.round(height)}`;
248
- }
249
-
250
- function buildQaSnapshot(
251
- document: LeviosaCanvasDocument,
252
- json: CanvasJson,
253
- ): TemplateQaSnapshot {
254
- const slotBindings = document.slot_bindings ?? {};
255
- const cleanJson = stripQaOverlayElements(json);
256
- const pages = asArray(cleanJson.pages) as Array<Record<string, unknown>>;
257
- const elementById = new Map<string, { element: CanvasJsonElement; pageId?: string }>();
258
- const slotByElementId = new Map<string, string[]>();
259
- const slotsFromElements = new Map<string, { element: CanvasJsonElement; pageId?: string }>();
260
- let elementCount = 0;
261
-
262
- walkElements(cleanJson, (element, page) => {
263
- elementCount += 1;
264
- if (element.id) {
265
- const pageId = typeof page.id === "string" ? page.id : undefined;
266
- elementById.set(element.id, { element, pageId });
267
- const slot = getSlotFromElement(element);
268
- if (slot) slotsFromElements.set(slot, { element, pageId });
269
- }
270
- });
271
-
272
- for (const [slot, binding] of Object.entries(slotBindings)) {
273
- const list = slotByElementId.get(binding.element_id) ?? [];
274
- list.push(slot);
275
- slotByElementId.set(binding.element_id, list);
276
- }
277
-
278
- const lint: TemplateLintIssue[] = [];
279
- const slots = Object.entries(slotBindings)
280
- .sort(([a], [b]) => a.localeCompare(b))
281
- .map(([slot, binding]) => {
282
- const target = elementById.get(binding.element_id);
283
- const element = target?.element;
284
- const inferredKind = getElementKind(element);
285
- let state: SlotInspection["state"] = "ok";
286
- let message = "연결됨";
287
-
288
- if (!element) {
289
- state = "error";
290
- message = "바인딩 대상 요소가 없습니다";
291
- lint.push({
292
- level: "error",
293
- title: "Missing binding target",
294
- detail: `${binding.element_id} 요소를 찾을 수 없습니다.`,
295
- slot,
296
- elementId: binding.element_id,
297
- });
298
- } else if (inferredKind && inferredKind !== binding.kind) {
299
- state = "warning";
300
- message = `${binding.kind} 슬롯이 ${element.type} 요소에 연결됨`;
301
- lint.push({
302
- level: "warning",
303
- title: "Slot kind mismatch",
304
- detail: `${binding.kind} 슬롯이 ${element.type} 요소에 연결되어 있습니다.`,
305
- slot,
306
- elementId: binding.element_id,
307
- });
308
- }
309
-
310
- if (element?.type === "text" && !String(element.text ?? "").trim()) {
311
- state = state === "error" ? state : "warning";
312
- message = "텍스트가 비어 있습니다";
313
- lint.push({
314
- level: "warning",
315
- title: "Empty text slot",
316
- detail: "텍스트 요소에 표시할 문구가 없습니다.",
317
- slot,
318
- elementId: binding.element_id,
319
- });
320
- }
321
-
322
- if (element?.type === "image" && !String(element.src ?? "").trim()) {
323
- state = state === "error" ? state : "warning";
324
- message = "이미지 URL이 비어 있습니다";
325
- lint.push({
326
- level: "warning",
327
- title: "Empty image slot",
328
- detail: "이미지 요소에 src가 없습니다.",
329
- slot,
330
- elementId: binding.element_id,
331
- });
332
- }
333
-
334
- const width = finiteNumber(element?.width);
335
- const height = finiteNumber(element?.height);
336
- if (element && (width === null || height === null || width <= 0 || height <= 0)) {
337
- state = state === "error" ? state : "warning";
338
- message = "요소 크기를 확인하세요";
339
- lint.push({
340
- level: "warning",
341
- title: "Invalid element bounds",
342
- detail: "요소의 width 또는 height가 0 이하입니다.",
343
- slot,
344
- elementId: binding.element_id,
345
- });
346
- }
347
-
348
- return {
349
- slot,
350
- binding,
351
- element,
352
- pageId: target?.pageId,
353
- state,
354
- message,
355
- };
356
- });
357
-
358
- for (const [elementId, slotNames] of slotByElementId) {
359
- if (slotNames.length > 1) {
360
- lint.push({
361
- level: "warning",
362
- title: "Duplicate element binding",
363
- detail: `${slotNames.join(", ")} 슬롯이 같은 요소를 공유합니다.`,
364
- elementId,
365
- });
366
- }
367
- }
368
-
369
- for (const [slot, target] of slotsFromElements) {
370
- if (!slotBindings[slot]) {
371
- lint.push({
372
- level: "info",
373
- title: "Unregistered slot marker",
374
- detail: "요소 custom.leviosaSlot은 있지만 slot_bindings에는 없습니다.",
375
- slot,
376
- elementId: target.element.id,
377
- });
378
- }
379
- }
380
-
381
- const canvasWidth = finiteNumber(document.canvas.width);
382
- const jsonWidth = finiteNumber(cleanJson.width);
383
- if (canvasWidth && jsonWidth && canvasWidth !== jsonWidth) {
384
- lint.push({
385
- level: "warning",
386
- title: "Canvas width drift",
387
- detail: `문서 canvas.width ${canvasWidth}와 문서 JSON width ${jsonWidth}가 다릅니다.`,
388
- });
389
- }
390
-
391
- const errors = lint.filter((issue) => issue.level === "error").length;
392
- const warnings = lint.filter((issue) => issue.level === "warning").length;
393
- const summary = [
394
- `Template: ${document.template_id ?? "unspecified"} v${document.template_version ?? "-"}`,
395
- `Canvas: ${cleanJson.width ?? document.canvas.width} x ${cleanJson.height ?? "-"}`,
396
- `Pages: ${pages.length}, Elements: ${elementCount}, Slots: ${slots.length}`,
397
- `Lint: ${errors} errors, ${warnings} warnings`,
398
- "",
399
- "Slots",
400
- ...slots.map(
401
- (slot) =>
402
- `- ${slot.slot}: ${slot.state.toUpperCase()} | ${slot.binding?.kind ?? "-"} | ${slot.binding?.element_id ?? "-"} | ${formatRect(slot.element)} | ${slot.message}`,
403
- ),
404
- "",
405
- "Lint",
406
- ...(lint.length
407
- ? lint.map(
408
- (issue) =>
409
- `- ${issue.level.toUpperCase()}: ${issue.title}${issue.slot ? ` (${issue.slot})` : ""} - ${issue.detail}`,
410
- )
411
- : ["- PASS: template lint found no blocking issues"]),
412
- ].join("\n");
413
-
414
- return {
415
- json: cleanJson,
416
- slots,
417
- lint,
418
- summary,
419
- counts: {
420
- pages: pages.length,
421
- elements: elementCount,
422
- slots: slots.length,
423
- errors,
424
- warnings,
425
- },
426
- };
427
- }
428
-
429
131
  export function DetailPageEditor({
430
132
  initialDocument,
431
133
  saving = false,
432
134
  uploadFile,
433
135
  onSave,
136
+ autoSaveDelayMs,
434
137
  onGenerateImage,
435
138
  onGenerateGif,
436
139
  onGenerateTextGif,
@@ -467,25 +170,23 @@ export function DetailPageEditor({
467
170
  const [saveOk, setSaveOk] = useState(false);
468
171
  // 편집 한도 소진 시 "편집 크레딧 추가하기" → pricing 모달을 인플레이스로 연다.
469
172
  const [pricingOpen, setPricingOpen] = useState(false);
470
- const [qaJson, setQaJson] = useState<CanvasJson>(initialDocument.canvas_json);
471
- const [overlayEnabled, setOverlayEnabled] = useState(false);
472
- const [summaryCopied, setSummaryCopied] = useState(false);
473
173
  // 어댑터를 안 거친다. `adaptLeviosaCustomProps`는 SDK가 **못 읽는** 장식을 읽을 수
474
174
  // 있는 모양으로 낮춰 주던 일이었고(그림자·그라데이션 → 대표 단색), 우리 렌더러는
475
175
  // 그 필드들을 원본 그대로 읽는다. 낮춘 것을 넣으면 오히려 원본보다 못한 그림이 된다.
476
176
  // 브랜드 자산 주소는 바이트 경로로 옮겨서 연다 — 302 를 따라가면 CORS 가 깨져서
477
177
  // 캔버스가 그림을 못 읽는다(asset-bytes-url 에 실측 표를 적어 뒀다).
478
- const store = useMemo(() => {
178
+ // 문서는 편집기가 열릴 때 **한 번만** 읽는다. 저장이 끝나고 앱이 서버가 돌려준
179
+ // 문서를 도로 넣어도 캔버스를 다시 만들지 않는다 — 다시 만들면 되돌리기 100단과
180
+ // 선택이 통째로 날아간다. 저장 버튼 한 번에 ⌘Z 가 죽던 것이 그 증상이었고,
181
+ // 자동저장이 붙으면 몇 초마다 그렇게 된다.
182
+ //
183
+ // 정말로 다른 문서를 여는 것은 부르는 쪽이 `key` 로 다시 마운트한다.
184
+ const [store] = useState(() => {
479
185
  ensureCanvasKey();
480
186
  return createCanvasStore(
481
187
  normalizeDocumentAssetSrcs(initialDocument.canvas_json) as DocumentJson,
482
188
  );
483
- }, [initialDocument.canvas_json]);
484
-
485
- const qaSnapshot = useMemo(
486
- () => buildQaSnapshot(initialDocument, qaJson),
487
- [initialDocument, qaJson],
488
- );
189
+ });
489
190
 
490
191
  // 글꼴 준비. 문서가 쓰는 얼굴을 전부 받아 놓고, 다 온 뒤에 줄바꿈 손질
491
192
  // (`applyTextLineFit`)을 한 번 돌린다 — 폴백 서체로 재면 과대측정해서 손질이
@@ -506,159 +207,30 @@ export function DetailPageEditor({
506
207
  };
507
208
  }, [store]);
508
209
 
509
- useEffect(() => {
510
- // Initial sync is immediate so the QA panel is populated on mount.
511
- setQaJson(stripQaOverlayElements(store.toJSON() as CanvasJson));
512
-
513
- // 스토어는 요소를 끄는 내내 "change"를 계속 쏜다 — 끌기·크기조절·타이핑 모두.
514
- // or typed into. Recomputing the QA snapshot on every event deep-clones and
515
- // walks the whole document many times per second, which freezes the canvas
516
- // layer on large detail-page documents. The QA panel does not need live
517
- // updates mid-gesture, so coalesce changes with a trailing debounce: the
518
- // snapshot refreshes ~300ms after the user stops interacting.
519
- let timer: ReturnType<typeof setTimeout> | null = null;
520
- // 바뀐 문서를 이벤트에 실어 받지 않고 **잠잠해진 뒤에** 한 번 읽는다. 글자 한 자마다
521
- // 문서 전체를 직렬화해 넘기면 그 비용이 타이핑 지연으로 그대로 보인다.
522
- const flush = () => {
523
- timer = null;
524
- setQaJson(stripQaOverlayElements(store.toJSON() as CanvasJson));
525
- };
526
- const off = store.on("change", () => {
527
- if (timer) clearTimeout(timer);
528
- timer = setTimeout(flush, 300);
529
- });
530
- return () => {
531
- if (timer) clearTimeout(timer);
532
- off();
533
- };
534
- }, [store]);
535
-
536
- useEffect(() => {
537
- setSummaryCopied(false);
538
- }, [qaSnapshot.summary]);
539
-
540
- const removeOverlayElements = () => {
541
- const ids: string[] = [];
542
- walkElements(store.toJSON() as CanvasJson, (element) => {
543
- if (element.id && isQaOverlayElement(element)) ids.push(element.id);
544
- });
545
- if (ids.length) store.deleteElements(ids);
546
- };
547
-
548
- const addOverlayElements = () => {
549
- removeOverlayElements();
550
- const cleanJson = qaSnapshot.json;
551
- const pages = asArray(cleanJson.pages) as Array<Record<string, unknown>>;
552
- for (const slot of qaSnapshot.slots) {
553
- if (!slot.element || !slot.element.id || !slot.pageId) continue;
554
- const pageIndex = pages.findIndex((page) => page.id === slot.pageId);
555
- const page = store.pages[pageIndex >= 0 ? pageIndex : 0];
556
- if (!page) continue;
557
- const x = finiteNumber(slot.element.x) ?? 0;
558
- const y = finiteNumber(slot.element.y) ?? 0;
559
- const width = Math.max(finiteNumber(slot.element.width) ?? 1, 1);
560
- const height = Math.max(finiteNumber(slot.element.height) ?? 1, 1);
561
- const color = slot.state === "error" ? "#dc2626" : slot.state === "warning" ? "#d97706" : "#059669";
562
- page.addElement(
563
- {
564
- id: `${QA_OVERLAY_PREFIX}-box-${slot.element.id}`,
565
- type: "figure",
566
- subType: "rect",
567
- x,
568
- y,
569
- width,
570
- height,
571
- fill: "rgba(255,255,255,0)",
572
- stroke: color,
573
- strokeWidth: 3,
574
- dash: [10, 6] as never,
575
- selectable: false,
576
- removable: false,
577
- alwaysOnTop: true,
578
- showInExport: false,
579
- custom: { [QA_OVERLAY_CUSTOM_KEY]: true, slot: slot.slot },
580
- },
581
- { skipSelect: true },
582
- );
583
- page.addElement(
584
- {
585
- id: `${QA_OVERLAY_PREFIX}-label-${slot.element.id}`,
586
- type: "text",
587
- x,
588
- y: Math.max(0, y - 30),
589
- width: Math.max(120, Math.min(width, 320)),
590
- height: 24,
591
- text: slot.slot,
592
- fontSize: 14,
593
- fontWeight: "700",
594
- fill: "#ffffff",
595
- backgroundEnabled: true,
596
- backgroundColor: color,
597
- backgroundOpacity: 0.92,
598
- backgroundCornerRadius: 4,
599
- backgroundPadding: 6,
600
- selectable: false,
601
- removable: false,
602
- alwaysOnTop: true,
603
- showInExport: false,
604
- custom: { [QA_OVERLAY_CUSTOM_KEY]: true, slot: slot.slot },
605
- },
606
- { skipSelect: true },
607
- );
608
- }
609
- };
610
-
611
- useEffect(() => {
612
- if (overlayEnabled) addOverlayElements();
613
- else removeOverlayElements();
614
- return removeOverlayElements;
615
- // The overlay is intentionally regenerated from the latest QA snapshot.
616
- // eslint-disable-next-line react-hooks/exhaustive-deps
617
- }, [overlayEnabled, qaSnapshot.summary, store]);
618
-
619
- const handleSave = async () => {
620
- setSaveError(null);
621
- setSaveOk(false);
622
- try {
623
- // Drop QA overlays and revert gray image placeholders to empty src so the
624
- // persisted document never carries the editor-only placeholder.
625
- const cleanJson = clearPlaceholderImageSrc(
626
- stripQaOverlayElements(store.toJSON() as CanvasJson),
627
- );
628
- await onSave({
629
- ...initialDocument,
630
- canvas_json: cleanJson,
631
- });
632
- setSaveOk(true);
633
- } catch (error) {
634
- setSaveError(error instanceof Error ? error.message : t("editor.saveError"));
635
- }
636
- };
637
-
638
- const handleRefreshQa = () => {
639
- setQaJson(stripQaOverlayElements(store.toJSON() as CanvasJson));
640
- };
641
-
642
- const handleSelectSlot = (slot: SlotInspection) => {
643
- if (slot.binding?.element_id) {
644
- store.selectElements([slot.binding.element_id]);
645
- }
646
- };
210
+ const documentRef = useRef(initialDocument);
211
+ documentRef.current = initialDocument;
212
+
213
+ const runSave = useCallback(
214
+ async (reason: SaveReason) => {
215
+ setSaveError(null);
216
+ setSaveOk(false);
217
+ try {
218
+ // 회색 자리표시 이미지는 빈 src 로 되돌려 저장한다 — 편집기에서만 쓰는 그림이다.
219
+ const cleanJson = clearPlaceholderImageSrc(store.toJSON() as CanvasJson);
220
+ await onSave({ ...documentRef.current, canvas_json: cleanJson }, { reason });
221
+ setSaveOk(true);
222
+ } catch (error) {
223
+ setSaveError(error instanceof Error ? error.message : t("editor.saveError"));
224
+ // 자동저장은 조용히 넘어가지만 실패는 알려 줘야 다음에 다시 보낸다.
225
+ if (reason !== "manual") throw error;
226
+ }
227
+ },
228
+ [onSave, store, t],
229
+ );
647
230
 
648
- const handleCopySummary = async () => {
649
- await navigator.clipboard.writeText(qaSnapshot.summary);
650
- setSummaryCopied(true);
651
- };
231
+ const handleSave = useCallback(() => void runSave("manual"), [runSave]);
652
232
 
653
- const handleDownloadSummary = () => {
654
- const blob = new Blob([qaSnapshot.summary], { type: "text/plain;charset=utf-8" });
655
- const url = URL.createObjectURL(blob);
656
- const link = document.createElement("a");
657
- link.href = url;
658
- link.download = `${initialDocument.template_id ?? "canvas-template"}-qa-summary.txt`;
659
- link.click();
660
- URL.revokeObjectURL(url);
661
- };
233
+ const unsaved = useAutoSave({ store, delayMs: autoSaveDelayMs, save: runSave });
662
234
 
663
235
  // The canvas subtree only depends on the stable store and sidebar
664
236
  // contract. Memoize it so QA/save/overlay updates never recreate the heavy
@@ -837,174 +409,6 @@ export function DetailPageEditor({
837
409
  dataGifCreditCost={dataGifCreditCost}
838
410
  />
839
411
  </div>
840
-
841
- {/* Template QA / slot inspector / lint — authoring tools, not seller-facing. */}
842
- <details className="border-t border-dpe-ink-200">
843
- <summary className="cursor-pointer p-4 text-xs font-dpe-semibold uppercase tracking-[0.06em] text-dpe-ink-400 hover:text-dpe-ink-600">
844
- 개발자 도구 · 템플릿 QA
845
- </summary>
846
- <div className="flex max-h-[55vh] min-h-0 flex-col overflow-y-auto">
847
- <div className="border-b border-dpe-ink-200 p-4">
848
- <div className="flex items-center justify-between gap-3">
849
- <div>
850
- <h2 className="flex items-center gap-2 text-sm font-dpe-semibold text-dpe-ink-950">
851
- <ListChecks aria-hidden="true" size={16} />
852
- {t("editor.qaTitle")}
853
- </h2>
854
- <p className="mt-1 text-xs text-dpe-ink-500">
855
- {qaSnapshot.counts.pages} pages / {qaSnapshot.counts.elements} elements /{" "}
856
- {qaSnapshot.counts.slots} slots
857
- </p>
858
- </div>
859
- <button
860
- type="button"
861
- onClick={handleRefreshQa}
862
- className="inline-flex h-8 w-8 items-center justify-center rounded-dpe-md border border-dpe-ink-200 text-dpe-ink-600 hover:bg-dpe-ink-50"
863
- title={t("editor.refreshQa")}
864
- >
865
- <RefreshCw aria-hidden="true" size={15} />
866
- </button>
867
- </div>
868
- <div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
869
- <div className="rounded-dpe-md border border-dpe-ink-200 p-2">
870
- <div className="text-base font-dpe-semibold text-dpe-ink-950">{qaSnapshot.counts.slots}</div>
871
- <div className="text-dpe-ink-500">slots</div>
872
- </div>
873
- <div className="rounded-dpe-md border border-dpe-danger-100 bg-dpe-danger-50 p-2">
874
- <div className="text-base font-dpe-semibold text-dpe-danger-700">{qaSnapshot.counts.errors}</div>
875
- <div className="text-dpe-danger-600">errors</div>
876
- </div>
877
- <div className="rounded-dpe-md border border-dpe-warn-100 bg-dpe-warn-50 p-2">
878
- <div className="text-base font-dpe-semibold text-dpe-warn-700">{qaSnapshot.counts.warnings}</div>
879
- <div className="text-dpe-warn-600">warnings</div>
880
- </div>
881
- </div>
882
- <div className="mt-4 flex gap-2">
883
- <button
884
- type="button"
885
- onClick={() => setOverlayEnabled((value) => !value)}
886
- className="inline-flex h-9 flex-1 items-center justify-center gap-2 rounded-dpe-md border border-dpe-ink-200 text-xs font-dpe-semibold text-dpe-ink-700 hover:bg-dpe-ink-50"
887
- >
888
- {overlayEnabled ? <EyeOff aria-hidden="true" size={15} /> : <Eye aria-hidden="true" size={15} />}
889
- {overlayEnabled ? t("editor.overlayOff") : t("editor.overlayOn")}
890
- </button>
891
- <button
892
- type="button"
893
- onClick={handleCopySummary}
894
- className="inline-flex h-9 w-9 items-center justify-center rounded-dpe-md border border-dpe-ink-200 text-dpe-ink-700 hover:bg-dpe-ink-50"
895
- title={t("editor.copySummary")}
896
- >
897
- <Copy aria-hidden="true" size={15} />
898
- </button>
899
- <button
900
- type="button"
901
- onClick={handleDownloadSummary}
902
- className="inline-flex h-9 w-9 items-center justify-center rounded-dpe-md border border-dpe-ink-200 text-dpe-ink-700 hover:bg-dpe-ink-50"
903
- title={t("editor.downloadSummary")}
904
- >
905
- <Download aria-hidden="true" size={15} />
906
- </button>
907
- </div>
908
- {summaryCopied ? (
909
- <p className="mt-2 text-xs font-dpe-medium text-dpe-ok-600">{t("editor.summaryCopied")}</p>
910
- ) : null}
911
- </div>
912
-
913
- <div className="min-h-0 flex-1 overflow-y-auto p-4">
914
- <section>
915
- <h3 className="text-xs font-dpe-semibold uppercase tracking-[0.08em] text-dpe-ink-500">
916
- {t("editor.slotInspector")}
917
- </h3>
918
- <div className="mt-3 space-y-2">
919
- {qaSnapshot.slots.length ? (
920
- qaSnapshot.slots.map((slot) => (
921
- <button
922
- key={slot.slot}
923
- type="button"
924
- onClick={() => handleSelectSlot(slot)}
925
- className="w-full rounded-dpe-md border border-dpe-ink-200 p-3 text-left hover:border-dpe-ink-300 hover:bg-dpe-ink-50"
926
- >
927
- <div className="flex items-center justify-between gap-2">
928
- <span className="truncate text-sm font-dpe-semibold text-dpe-ink-950">{slot.slot}</span>
929
- <span
930
- className={
931
- slot.state === "error"
932
- ? "text-xs font-dpe-semibold text-dpe-danger-600"
933
- : slot.state === "warning"
934
- ? "text-xs font-dpe-semibold text-dpe-warn-600"
935
- : "text-xs font-dpe-semibold text-dpe-ok-600"
936
- }
937
- >
938
- {slot.state}
939
- </span>
940
- </div>
941
- <p className="mt-1 truncate text-xs text-dpe-ink-500">
942
- {slot.binding?.kind ?? "-"} / {slot.binding?.element_id ?? "-"}
943
- </p>
944
- <p className="mt-1 text-xs text-dpe-ink-600">{formatRect(slot.element)}</p>
945
- <p className="mt-2 text-xs text-dpe-ink-500">{slot.message}</p>
946
- </button>
947
- ))
948
- ) : (
949
- <p className="rounded-dpe-md border border-dpe-ink-200 p-3 text-sm text-dpe-ink-500">
950
- {t("editor.noSlots")}
951
- </p>
952
- )}
953
- </div>
954
- </section>
955
-
956
- <section className="mt-6">
957
- <h3 className="text-xs font-dpe-semibold uppercase tracking-[0.08em] text-dpe-ink-500">
958
- {t("editor.templateLint")}
959
- </h3>
960
- <div className="mt-3 space-y-2">
961
- {qaSnapshot.lint.length ? (
962
- qaSnapshot.lint.map((issue, index) => (
963
- <div
964
- key={`${issue.title}-${issue.slot ?? issue.elementId ?? index}`}
965
- className="rounded-dpe-md border border-dpe-ink-200 p-3"
966
- >
967
- <div className="flex items-center justify-between gap-2">
968
- <p className="text-sm font-dpe-semibold text-dpe-ink-950">{issue.title}</p>
969
- <span
970
- className={
971
- issue.level === "error"
972
- ? "text-xs font-dpe-semibold text-dpe-danger-600"
973
- : issue.level === "warning"
974
- ? "text-xs font-dpe-semibold text-dpe-warn-600"
975
- : "text-xs font-dpe-semibold text-dpe-active-600"
976
- }
977
- >
978
- {issue.level}
979
- </span>
980
- </div>
981
- <p className="mt-1 text-xs leading-5 text-dpe-ink-600">{issue.detail}</p>
982
- {issue.slot || issue.elementId ? (
983
- <p className="mt-2 truncate text-xs text-dpe-ink-400">
984
- {[issue.slot, issue.elementId].filter(Boolean).join(" / ")}
985
- </p>
986
- ) : null}
987
- </div>
988
- ))
989
- ) : (
990
- <div className="rounded-dpe-md border border-dpe-ok-100 bg-dpe-ok-50 p-3 text-sm font-dpe-medium text-dpe-ok-700">
991
- {t("editor.lintPassed")}
992
- </div>
993
- )}
994
- </div>
995
- </section>
996
-
997
- <section className="mt-6">
998
- <h3 className="text-xs font-dpe-semibold uppercase tracking-[0.08em] text-dpe-ink-500">
999
- {t("editor.designerSummary")}
1000
- </h3>
1001
- <pre className="mt-3 max-h-72 overflow-auto rounded-dpe-md border border-dpe-ink-200 bg-dpe-ink-50 p-3 text-xs leading-5 text-dpe-ink-700">
1002
- {qaSnapshot.summary}
1003
- </pre>
1004
- </section>
1005
- </div>
1006
- </div>
1007
- </details>
1008
412
  </aside>
1009
413
  );
1010
414
  // 인스펙터는 감싸 쓰는 경우가 더 많다 — 자기 크롬만 두르고 속은 그대로 둔다.
@@ -1018,7 +422,7 @@ export function DetailPageEditor({
1018
422
  <HeaderSlot
1019
423
  productName={productName?.trim() || t("editor.untitled")}
1020
424
  onBack={onBack}
1021
- save={{ run: handleSave, saving, ok: saveOk, error: saveError }}
425
+ save={{ run: handleSave, saving, ok: saveOk, error: saveError, unsaved }}
1022
426
  parts={{
1023
427
  history: historyPart,
1024
428
  download: downloadPart,