@things-factory/board-ui 10.0.0-beta.8 → 10.0.0-beta.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/dist-client/apptools/favorite-tool.js +5 -5
  2. package/dist-client/apptools/favorite-tool.js.map +1 -1
  3. package/dist-client/board-list/board-tile-list.d.ts +6 -3
  4. package/dist-client/board-list/board-tile-list.js +316 -62
  5. package/dist-client/board-list/board-tile-list.js.map +1 -1
  6. package/dist-client/board-list/group-bar.js +3 -3
  7. package/dist-client/board-list/group-bar.js.map +1 -1
  8. package/dist-client/board-list/play-group-bar.d.ts +0 -1
  9. package/dist-client/board-list/play-group-bar.js +3 -6
  10. package/dist-client/board-list/play-group-bar.js.map +1 -1
  11. package/dist-client/board-provider.js +20 -8
  12. package/dist-client/board-provider.js.map +1 -1
  13. package/dist-client/data-grist/board-editor.js +4 -4
  14. package/dist-client/data-grist/board-editor.js.map +1 -1
  15. package/dist-client/data-grist/board-renderer.js +4 -4
  16. package/dist-client/data-grist/board-renderer.js.map +1 -1
  17. package/dist-client/graphql/attachment.d.ts +33 -0
  18. package/dist-client/graphql/attachment.js +87 -0
  19. package/dist-client/graphql/attachment.js.map +1 -0
  20. package/dist-client/graphql/board-import.d.ts +45 -0
  21. package/dist-client/graphql/board-import.js +104 -0
  22. package/dist-client/graphql/board-import.js.map +1 -0
  23. package/dist-client/graphql/board-template.js +1 -1
  24. package/dist-client/graphql/board-template.js.map +1 -1
  25. package/dist-client/graphql/board.d.ts +1 -0
  26. package/dist-client/graphql/board.js +28 -2
  27. package/dist-client/graphql/board.js.map +1 -1
  28. package/dist-client/graphql/group.js +1 -1
  29. package/dist-client/graphql/group.js.map +1 -1
  30. package/dist-client/graphql/play-group.js +3 -3
  31. package/dist-client/graphql/play-group.js.map +1 -1
  32. package/dist-client/pages/attachment-list-page.d.ts +16 -0
  33. package/dist-client/pages/attachment-list-page.js +63 -2
  34. package/dist-client/pages/attachment-list-page.js.map +1 -1
  35. package/dist-client/pages/board-action-dispatch.d.ts +31 -0
  36. package/dist-client/pages/board-action-dispatch.js +80 -0
  37. package/dist-client/pages/board-action-dispatch.js.map +1 -0
  38. package/dist-client/pages/board-action-dispatch.test.d.ts +1 -0
  39. package/dist-client/pages/board-action-dispatch.test.js +235 -0
  40. package/dist-client/pages/board-action-dispatch.test.js.map +1 -0
  41. package/dist-client/pages/board-create-wizard-page.d.ts +157 -0
  42. package/dist-client/pages/board-create-wizard-page.js +2176 -0
  43. package/dist-client/pages/board-create-wizard-page.js.map +1 -0
  44. package/dist-client/pages/board-edit-dispatch.d.ts +74 -0
  45. package/dist-client/pages/board-edit-dispatch.js +299 -0
  46. package/dist-client/pages/board-edit-dispatch.js.map +1 -0
  47. package/dist-client/pages/board-edit-dispatch.test.d.ts +1 -0
  48. package/dist-client/pages/board-edit-dispatch.test.js +858 -0
  49. package/dist-client/pages/board-edit-dispatch.test.js.map +1 -0
  50. package/dist-client/pages/board-list-page.d.ts +23 -3
  51. package/dist-client/pages/board-list-page.js +165 -77
  52. package/dist-client/pages/board-list-page.js.map +1 -1
  53. package/dist-client/pages/board-modeller-page.d.ts +134 -0
  54. package/dist-client/pages/board-modeller-page.js +725 -54
  55. package/dist-client/pages/board-modeller-page.js.map +1 -1
  56. package/dist-client/pages/board-player-by-name-page.js.map +1 -1
  57. package/dist-client/pages/board-player-page.js +14 -26
  58. package/dist-client/pages/board-player-page.js.map +1 -1
  59. package/dist-client/pages/board-viewer-by-name-page.d.ts +8 -1
  60. package/dist-client/pages/board-viewer-by-name-page.js +9 -1
  61. package/dist-client/pages/board-viewer-by-name-page.js.map +1 -1
  62. package/dist-client/pages/board-viewer-page.d.ts +2 -1
  63. package/dist-client/pages/board-viewer-page.js +52 -48
  64. package/dist-client/pages/board-viewer-page.js.map +1 -1
  65. package/dist-client/pages/play-list-page.d.ts +0 -1
  66. package/dist-client/pages/play-list-page.js +26 -33
  67. package/dist-client/pages/play-list-page.js.map +1 -1
  68. package/dist-client/pages/printable-board-viewer-page.js +2 -2
  69. package/dist-client/pages/printable-board-viewer-page.js.map +1 -1
  70. package/dist-client/route.d.ts +1 -1
  71. package/dist-client/route.js +3 -0
  72. package/dist-client/route.js.map +1 -1
  73. package/dist-client/setting-let/board-view-setting-let.js +1 -1
  74. package/dist-client/setting-let/board-view-setting-let.js.map +1 -1
  75. package/dist-client/tsconfig.tsbuildinfo +1 -1
  76. package/dist-client/utils/notify-helper.d.ts +7 -0
  77. package/dist-client/utils/notify-helper.js +28 -0
  78. package/dist-client/utils/notify-helper.js.map +1 -0
  79. package/dist-client/utils/query-utils.d.ts +1 -0
  80. package/dist-client/utils/query-utils.js +20 -0
  81. package/dist-client/utils/query-utils.js.map +1 -0
  82. package/dist-client/viewparts/board-basic-info.js +9 -13
  83. package/dist-client/viewparts/board-basic-info.js.map +1 -1
  84. package/dist-client/viewparts/board-template-info.d.ts +0 -1
  85. package/dist-client/viewparts/board-template-info.js +5 -13
  86. package/dist-client/viewparts/board-template-info.js.map +1 -1
  87. package/dist-client/viewparts/board-versions.js +1 -1
  88. package/dist-client/viewparts/board-versions.js.map +1 -1
  89. package/dist-client/viewparts/group-info-basic.js +2 -2
  90. package/dist-client/viewparts/group-info-basic.js.map +1 -1
  91. package/dist-client/viewparts/group-info-import.js +2 -2
  92. package/dist-client/viewparts/group-info-import.js.map +1 -1
  93. package/dist-client/viewparts/link-builder.js +1 -1
  94. package/dist-client/viewparts/link-builder.js.map +1 -1
  95. package/dist-client/viewparts/play-group-info-basic.js +2 -2
  96. package/dist-client/viewparts/play-group-info-basic.js.map +1 -1
  97. package/dist-server/tsconfig.tsbuildinfo +1 -1
  98. package/package.json +5 -4
  99. package/things-factory.config.js +1 -0
  100. package/translations/en.json +71 -30
  101. package/translations/ja.json +3 -29
  102. package/translations/ko.json +71 -30
  103. package/translations/ms.json +3 -29
  104. package/translations/zh.json +3 -29
@@ -0,0 +1,2176 @@
1
+ import { __decorate, __metadata } from "tslib";
2
+ /**
3
+ * <board-create-wizard-page> — 보드 생성 wizard.
4
+ *
5
+ * 세 가지 모드:
6
+ * 처음부터 (blank / template) — 빈 캔버스 또는 기존 템플릿으로 새 보드 생성.
7
+ * 도면 업로드 (new) — DXF / 이미지 업로드 → board-import 파이프라인으로 자동 변환.
8
+ * 기존 파일 (existing) — 이미 업로드된 Attachment 에서 보드 생성.
9
+ */
10
+ import '@material/web/icon/icon.js';
11
+ import { css, html } from 'lit';
12
+ import { customElement, state } from 'lit/decorators.js';
13
+ import { i18next, localize } from '@operato/i18n';
14
+ import { navigate, PageView } from '@operato/shell';
15
+ import { notify } from '@operato/layout';
16
+ import '@operato/board/ox-board-preview.js';
17
+ import { createBoard } from '../graphql/board';
18
+ import { fetchGroupList } from '../graphql/group';
19
+ import { fetchBoardTemplateList, fetchBoardTemplate } from '../graphql/board-template';
20
+ import { createAttachmentForImport, fetchAttachmentsForImport } from '../graphql/attachment';
21
+ import { importBoardAsync, fetchImportSession, materializeImportSession, suggestBoardMeta } from '../graphql/board-import';
22
+ /** 허용 도면 형식 — board-import 의 어댑터 set 에 맞춤. */
23
+ const ACCEPT = '.dxf,.png,.jpg,.jpeg,.webp,.gif,.bmp';
24
+ let BoardCreateWizardPage = class BoardCreateWizardPage extends localize(i18next)(PageView) {
25
+ constructor() {
26
+ super(...arguments);
27
+ /** 현재 wizard 단계. URL/params 와는 독립 — 페이지 안 state 만으로 진행. */
28
+ this._step = 'upload';
29
+ /** 현재 업로드 진행 중 — UI lock + spinner. */
30
+ this._uploading = false;
31
+ /** upload 스텝 안의 모드 — 새 파일 업로드 vs 기존 attachment 선택. */
32
+ this._uploadMode = 'blank';
33
+ /** 기존 파일 목록 (모드 전환 시 lazy fetch). */
34
+ this._browseItems = [];
35
+ /** 기존 파일 목록 로딩 / 검색 상태. */
36
+ this._browseLoading = false;
37
+ this._browseSearch = '';
38
+ /** 사용자가 도면에 대해 추가하는 자유서술 — VLM 에 hint 로 전달. */
39
+ this._userPrompt = '';
40
+ /** 템플릿 목록 (template 탭 진입 시 lazy fetch). */
41
+ this._templates = [];
42
+ this._templatesLoading = false;
43
+ this._templatesLoaded = false;
44
+ this._templateSearch = '';
45
+ /** 템플릿 모드 — 선택된 템플릿 (id, model, thumbnail). */
46
+ this._selectedTemplate = undefined;
47
+ /** 보드 그룹 목록 (lazy fetch). */
48
+ this._groups = [];
49
+ this._groupsLoaded = false;
50
+ /** 새 보드의 group / type — 모든 모드 공통. */
51
+ this._selectedGroupId = '';
52
+ this._selectedBoardType = 'main';
53
+ /** review step — 새 Board 이름 (짧고 심플). */
54
+ this._newBoardName = '';
55
+ /** review step — 새 Board 설명 (자세한 narrative). */
56
+ this._newBoardDescription = '';
57
+ /** review step — 사용자가 선택한 시안 id. 미선택 시 default (첫 variant). */
58
+ this._selectedVariantId = '';
59
+ /** materialize 진행 중 — 중복 클릭 방지. */
60
+ this._materializing = false;
61
+ }
62
+ static { this.styles = [
63
+ css `
64
+ :host {
65
+ display: flex;
66
+ flex-direction: column;
67
+ align-items: stretch;
68
+ padding: var(--spacing-large, 16px);
69
+ gap: var(--spacing-medium, 12px);
70
+ height: 100%;
71
+ background: var(--md-sys-color-surface, #fafafa);
72
+ color: var(--md-sys-color-on-surface, #1a1a1a);
73
+ overflow-y: auto;
74
+ box-sizing: border-box;
75
+ }
76
+ :host::after {
77
+ content: '';
78
+ display: block;
79
+ flex-shrink: 0;
80
+ height: var(--spacing-large, 16px);
81
+ }
82
+
83
+ .header {
84
+ display: flex;
85
+ flex-direction: column;
86
+ gap: 4px;
87
+ }
88
+ .header h1 {
89
+ margin: 0;
90
+ font-size: 20px;
91
+ font-weight: 600;
92
+ }
93
+ .header p {
94
+ margin: 0;
95
+ font-size: 13px;
96
+ color: var(--md-sys-color-on-secondary-container, #555);
97
+ }
98
+
99
+ .stepper {
100
+ display: flex;
101
+ align-items: center;
102
+ gap: 8px;
103
+ padding: 8px 12px;
104
+ background: var(--md-sys-color-surface-container, #fff);
105
+ border: 1px solid var(--md-sys-color-outline-variant, #e0e0e0);
106
+ border-radius: 8px;
107
+ }
108
+ .step {
109
+ display: inline-flex;
110
+ align-items: center;
111
+ gap: 6px;
112
+ padding: 4px 10px;
113
+ border-radius: 16px;
114
+ font-size: 12px;
115
+ color: var(--md-sys-color-on-secondary-container, #777);
116
+ background: transparent;
117
+ border: 1px solid transparent;
118
+ }
119
+ .step.active {
120
+ background: var(--md-sys-color-primary-container, #e7f0ff);
121
+ color: var(--md-sys-color-on-primary-container, #0b3a8a);
122
+ border-color: var(--md-sys-color-primary, #2a64d8);
123
+ font-weight: 600;
124
+ }
125
+ .step.done {
126
+ color: var(--md-sys-color-tertiary, #2e7d32);
127
+ }
128
+ .step .index {
129
+ width: 18px;
130
+ height: 18px;
131
+ border-radius: 50%;
132
+ background: var(--md-sys-color-outline-variant, #cfd8dc);
133
+ color: #fff;
134
+ font-size: 11px;
135
+ line-height: 18px;
136
+ text-align: center;
137
+ }
138
+ .step.active .index {
139
+ background: var(--md-sys-color-primary, #2a64d8);
140
+ }
141
+ .step.done .index {
142
+ background: var(--md-sys-color-tertiary, #2e7d32);
143
+ }
144
+ .stepper > .arrow {
145
+ opacity: 0.4;
146
+ font-size: 14px;
147
+ }
148
+
149
+ .stage {
150
+ /* flex: 1 0 auto — 빈 공간 있으면 늘어나서 시각 채움, content 가 더 크면 content 크기
151
+ * 그대로 (단순 flex:1 은 :host height 에 고정 → content 가 잘리는 회귀). */
152
+ flex: 1 0 auto;
153
+ display: flex;
154
+ flex-direction: column;
155
+ gap: 12px;
156
+ padding: 16px;
157
+ background: var(--md-sys-color-surface-container, #fff);
158
+ border: 1px solid var(--md-sys-color-outline-variant, #e0e0e0);
159
+ border-radius: 8px;
160
+ }
161
+
162
+ .placeholder {
163
+ display: flex;
164
+ flex-direction: column;
165
+ align-items: center;
166
+ justify-content: center;
167
+ gap: 8px;
168
+ height: 100%;
169
+ min-height: 240px;
170
+ color: var(--md-sys-color-on-secondary-container, #777);
171
+ text-align: center;
172
+ }
173
+ .placeholder .big {
174
+ font-size: 16px;
175
+ font-weight: 500;
176
+ }
177
+ .placeholder .small {
178
+ font-size: 12px;
179
+ max-width: 320px;
180
+ }
181
+
182
+ .controls {
183
+ display: flex;
184
+ justify-content: flex-end;
185
+ gap: 8px;
186
+ }
187
+
188
+ /* upload step UI */
189
+ .drop-zone {
190
+ flex: 1;
191
+ display: flex;
192
+ flex-direction: column;
193
+ align-items: center;
194
+ justify-content: center;
195
+ gap: 12px;
196
+ min-height: 240px;
197
+ padding: 24px;
198
+ border: 2px dashed var(--md-sys-color-outline-variant, #b8c2cc);
199
+ border-radius: 12px;
200
+ background: var(--md-sys-color-surface-container-lowest, #fafbfc);
201
+ cursor: pointer;
202
+ transition: border-color 0.15s, background 0.15s;
203
+ }
204
+ .drop-zone:hover,
205
+ .drop-zone.dragover {
206
+ border-color: var(--md-sys-color-primary, #2a64d8);
207
+ background: var(--md-sys-color-primary-container, #eaf2ff);
208
+ }
209
+ .drop-zone .icon {
210
+ font-size: 36px;
211
+ --md-icon-size: 36px;
212
+ opacity: 0.5;
213
+ }
214
+ .drop-zone .title {
215
+ font-size: 15px;
216
+ font-weight: 500;
217
+ }
218
+ .drop-zone .hint {
219
+ font-size: 12px;
220
+ color: var(--md-sys-color-on-secondary-container, #777);
221
+ }
222
+ input[type='file'] {
223
+ display: none;
224
+ }
225
+
226
+ .uploaded {
227
+ display: flex;
228
+ align-items: center;
229
+ gap: 12px;
230
+ padding: 12px 16px;
231
+ background: var(--md-sys-color-tertiary-container, #e8f5e9);
232
+ border: 1px solid var(--md-sys-color-tertiary, #2e7d32);
233
+ border-radius: 8px;
234
+ }
235
+ .uploaded .name {
236
+ font-weight: 500;
237
+ }
238
+ .uploaded .meta {
239
+ font-size: 12px;
240
+ opacity: 0.7;
241
+ margin-left: auto;
242
+ }
243
+
244
+ /* upload mode tabs — slim underline-only */
245
+ .mode-tabs {
246
+ display: flex;
247
+ gap: 24px;
248
+ border-bottom: 1px solid var(--md-sys-color-outline-variant, #e0e0e0);
249
+ margin-bottom: 8px;
250
+ padding: 0 4px;
251
+ }
252
+ .mode-tab {
253
+ padding: 8px 0 6px;
254
+ cursor: pointer;
255
+ font-size: 13px;
256
+ font-weight: 400;
257
+ color: var(--md-sys-color-on-secondary-container, #777);
258
+ border: none;
259
+ border-bottom: 2px solid transparent;
260
+ background: transparent;
261
+ margin-bottom: -1px;
262
+ transition: color 0.12s, border-color 0.12s;
263
+ text-transform: capitalize;
264
+ }
265
+ .mode-tab:hover {
266
+ color: var(--md-sys-color-on-surface, #1a1a1a);
267
+ }
268
+ .mode-tab.active {
269
+ color: var(--md-sys-color-primary, #2a64d8);
270
+ border-bottom-color: var(--md-sys-color-primary, #2a64d8);
271
+ font-weight: 500;
272
+ }
273
+ .mode-tabs.locked .mode-tab:not(.active) {
274
+ opacity: 0.35;
275
+ cursor: not-allowed;
276
+ }
277
+
278
+ /* "처음부터" 탭 — popup 스타일 two-column */
279
+ .scratch-layout {
280
+ display: flex;
281
+ flex-direction: row;
282
+ gap: 16px;
283
+ flex: 1 0 auto;
284
+ min-height: 400px;
285
+ }
286
+ .scratch-layout .quick-form {
287
+ display: flex;
288
+ flex-direction: column;
289
+ gap: 12px;
290
+ width: 260px;
291
+ flex-shrink: 0;
292
+ }
293
+ .template-panel {
294
+ flex: 1;
295
+ display: flex;
296
+ flex-direction: column;
297
+ gap: 6px;
298
+ overflow: hidden;
299
+ }
300
+ .template-panel-label {
301
+ font-size: 12px;
302
+ font-weight: 500;
303
+ display: flex;
304
+ align-items: baseline;
305
+ gap: 6px;
306
+ }
307
+ .template-panel-hint {
308
+ font-size: 11px;
309
+ font-weight: 400;
310
+ color: var(--md-sys-color-on-secondary-container, #888);
311
+ }
312
+ /* "템플릿" 탭 — 1컬럼 레이아웃 */
313
+ .template-layout {
314
+ display: flex;
315
+ flex-direction: column;
316
+ gap: 16px;
317
+ }
318
+ .template-layout .quick-form {
319
+ display: flex;
320
+ flex-direction: column;
321
+ gap: 12px;
322
+ }
323
+ .template-selected-name {
324
+ font-size: 11px;
325
+ font-weight: 600;
326
+ color: var(--md-sys-color-primary, #2a64d8);
327
+ margin-left: 2px;
328
+ }
329
+ .browse-item.selected {
330
+ border-color: var(--md-sys-color-primary, #2a64d8);
331
+ background: var(--md-sys-color-primary-container, #eaf2ff);
332
+ outline: 2px solid var(--md-sys-color-primary, #2a64d8);
333
+ outline-offset: -1px;
334
+ }
335
+
336
+ /* browse existing list */
337
+ .browse-toolbar {
338
+ display: flex;
339
+ gap: 8px;
340
+ align-items: center;
341
+ }
342
+ .browse-search {
343
+ flex: 1;
344
+ padding: 6px 10px;
345
+ font-size: 13px;
346
+ border: 1px solid var(--md-sys-color-outline-variant, #d0d7de);
347
+ border-radius: 6px;
348
+ }
349
+ .browse-list {
350
+ display: grid;
351
+ grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
352
+ gap: 10px;
353
+ max-height: 320px;
354
+ overflow-y: auto;
355
+ padding: 4px;
356
+ }
357
+ .browse-item {
358
+ display: flex;
359
+ flex-direction: column;
360
+ gap: 4px;
361
+ padding: 8px;
362
+ border: 1px solid var(--md-sys-color-outline-variant, #e0e0e0);
363
+ border-radius: 8px;
364
+ background: var(--md-sys-color-surface-container-lowest, #fafbfc);
365
+ cursor: pointer;
366
+ transition: border-color 0.12s, background 0.12s;
367
+ }
368
+ .browse-item:hover {
369
+ border-color: var(--md-sys-color-primary, #2a64d8);
370
+ background: var(--md-sys-color-primary-container, #eaf2ff);
371
+ }
372
+ .browse-item .thumb {
373
+ width: 100%;
374
+ height: 90px;
375
+ object-fit: cover;
376
+ background: #e9ecef;
377
+ border-radius: 4px;
378
+ }
379
+ .browse-item .thumb-fallback {
380
+ width: 100%;
381
+ height: 90px;
382
+ display: flex;
383
+ align-items: center;
384
+ justify-content: center;
385
+ background: #e9ecef;
386
+ border-radius: 4px;
387
+ color: #888;
388
+ font-size: 11px;
389
+ }
390
+ .browse-item .name {
391
+ font-size: 12px;
392
+ font-weight: 500;
393
+ white-space: nowrap;
394
+ overflow: hidden;
395
+ text-overflow: ellipsis;
396
+ }
397
+ .browse-item .meta {
398
+ font-size: 11px;
399
+ color: var(--md-sys-color-on-secondary-container, #777);
400
+ }
401
+ .browse-empty {
402
+ padding: 32px;
403
+ text-align: center;
404
+ color: var(--md-sys-color-on-secondary-container, #777);
405
+ font-size: 13px;
406
+ }
407
+
408
+ /* file preview pane */
409
+ .preview-pane {
410
+ display: flex;
411
+ flex-direction: column;
412
+ gap: 8px;
413
+ padding: 12px;
414
+ background: var(--md-sys-color-surface-container-lowest, #fafbfc);
415
+ border: 1px solid var(--md-sys-color-outline-variant, #e0e0e0);
416
+ border-radius: 8px;
417
+ }
418
+ .preview-pane .preview-image {
419
+ max-width: 100%;
420
+ max-height: 360px;
421
+ object-fit: contain;
422
+ background: #f0f0f0;
423
+ border-radius: 4px;
424
+ align-self: center;
425
+ }
426
+ .preview-pane .preview-fallback {
427
+ padding: 32px;
428
+ text-align: center;
429
+ color: var(--md-sys-color-on-secondary-container, #777);
430
+ font-size: 13px;
431
+ background: #f0f0f0;
432
+ border-radius: 4px;
433
+ }
434
+ .preview-pane .info-row {
435
+ display: flex;
436
+ font-size: 12px;
437
+ gap: 8px;
438
+ }
439
+ .preview-pane .info-row .label {
440
+ color: var(--md-sys-color-on-secondary-container, #777);
441
+ min-width: 80px;
442
+ text-transform: capitalize;
443
+ }
444
+
445
+ /* user prompt textarea */
446
+ .user-prompt {
447
+ display: flex;
448
+ flex-direction: column;
449
+ gap: 6px;
450
+ }
451
+ .user-prompt label {
452
+ display: flex;
453
+ flex-direction: column;
454
+ gap: 2px;
455
+ font-size: 13px;
456
+ font-weight: 500;
457
+ text-transform: capitalize;
458
+ }
459
+ .user-prompt label .hint {
460
+ font-size: 11px;
461
+ font-weight: 400;
462
+ color: var(--md-sys-color-on-secondary-container, #777);
463
+ }
464
+ .user-prompt textarea {
465
+ font-family: inherit;
466
+ font-size: 13px;
467
+ padding: 8px 10px;
468
+ border: 1px solid var(--md-sys-color-outline-variant, #d0d7de);
469
+ border-radius: 6px;
470
+ resize: vertical;
471
+ min-height: 60px;
472
+ }
473
+ .user-prompt textarea:focus {
474
+ outline: none;
475
+ border-color: var(--md-sys-color-primary, #2a64d8);
476
+ }
477
+
478
+ .error-banner {
479
+ padding: 8px 12px;
480
+ background: var(--md-sys-color-error-container, #fde7e7);
481
+ color: var(--md-sys-color-on-error-container, #b91c1c);
482
+ border-radius: 6px;
483
+ font-size: 13px;
484
+ }
485
+
486
+ /* progress step UI */
487
+ .progress-card {
488
+ display: flex;
489
+ flex-direction: column;
490
+ gap: 12px;
491
+ padding: 24px;
492
+ background: var(--md-sys-color-surface-container-lowest, #fafbfc);
493
+ border: 1px solid var(--md-sys-color-outline-variant, #e0e0e0);
494
+ border-radius: 12px;
495
+ }
496
+ .progress-status {
497
+ display: flex;
498
+ align-items: center;
499
+ gap: 12px;
500
+ }
501
+ .progress-status .label {
502
+ font-size: 14px;
503
+ font-weight: 500;
504
+ }
505
+ .progress-status .pct {
506
+ margin-left: auto;
507
+ font-size: 13px;
508
+ color: var(--md-sys-color-on-secondary-container, #777);
509
+ }
510
+ .progress-bar {
511
+ position: relative;
512
+ height: 6px;
513
+ background: var(--md-sys-color-outline-variant, #e0e0e0);
514
+ border-radius: 3px;
515
+ overflow: hidden;
516
+ }
517
+ .progress-bar > .fill {
518
+ position: absolute;
519
+ left: 0;
520
+ top: 0;
521
+ bottom: 0;
522
+ background: var(--md-sys-color-primary, #2a64d8);
523
+ transition: width 0.3s;
524
+ }
525
+ .progress-message {
526
+ font-size: 12px;
527
+ color: var(--md-sys-color-on-secondary-container, #777);
528
+ }
529
+
530
+ /* review step */
531
+ .review-stats {
532
+ display: flex;
533
+ gap: 12px;
534
+ flex-wrap: wrap;
535
+ }
536
+ .stat {
537
+ flex: 1;
538
+ min-width: 120px;
539
+ padding: 12px;
540
+ background: var(--md-sys-color-surface-container-lowest, #fafbfc);
541
+ border: 1px solid var(--md-sys-color-outline-variant, #e0e0e0);
542
+ border-radius: 8px;
543
+ display: flex;
544
+ flex-direction: column;
545
+ gap: 4px;
546
+ }
547
+ .stat .label {
548
+ font-size: 11px;
549
+ color: var(--md-sys-color-on-secondary-container, #777);
550
+ text-transform: uppercase;
551
+ }
552
+ .stat .value {
553
+ font-size: 22px;
554
+ font-weight: 600;
555
+ }
556
+ .warnings {
557
+ max-height: 160px;
558
+ overflow-y: auto;
559
+ padding: 8px 12px;
560
+ background: var(--md-sys-color-tertiary-container, #fff8e1);
561
+ border: 1px solid var(--md-sys-color-tertiary, #b08e00);
562
+ border-radius: 6px;
563
+ font-size: 12px;
564
+ }
565
+ .warnings ul {
566
+ margin: 4px 0 0;
567
+ padding-left: 16px;
568
+ }
569
+
570
+ /* AI 분석 요약 카드 */
571
+ .ai-summary {
572
+ display: flex;
573
+ flex-direction: column;
574
+ gap: 6px;
575
+ padding: 12px 14px;
576
+ background: var(--md-sys-color-primary-container, #eaf2ff);
577
+ border: 1px solid var(--md-sys-color-primary, #2a64d8);
578
+ border-radius: 8px;
579
+ }
580
+ .ai-summary .title {
581
+ font-weight: 600;
582
+ font-size: 13px;
583
+ }
584
+ .ai-summary .row {
585
+ display: flex;
586
+ gap: 8px;
587
+ font-size: 12px;
588
+ }
589
+ .ai-summary .row .label {
590
+ color: var(--md-sys-color-on-secondary-container, #555);
591
+ min-width: 100px;
592
+ flex-shrink: 0;
593
+ text-transform: capitalize;
594
+ }
595
+ .ai-summary .row .value {
596
+ flex: 1;
597
+ word-break: break-word;
598
+ }
599
+ .ai-summary .badge {
600
+ display: inline-block;
601
+ padding: 1px 6px;
602
+ margin-right: 4px;
603
+ font-size: 11px;
604
+ background: var(--md-sys-color-surface, #fff);
605
+ border: 1px solid var(--md-sys-color-outline-variant, #d0d7de);
606
+ border-radius: 10px;
607
+ }
608
+ .ai-summary .strategy {
609
+ margin-top: 4px;
610
+ padding: 8px 10px;
611
+ background: var(--md-sys-color-surface, #fff);
612
+ border-left: 3px solid var(--md-sys-color-primary, #2a64d8);
613
+ border-radius: 4px;
614
+ }
615
+ .ai-summary .strategy-label {
616
+ font-size: 12px;
617
+ font-weight: 600;
618
+ color: var(--md-sys-color-primary, #2a64d8);
619
+ margin-bottom: 4px;
620
+ }
621
+ .ai-summary .strategy-text {
622
+ font-size: 12px;
623
+ line-height: 1.5;
624
+ color: var(--md-sys-color-on-surface, #1a1a1a);
625
+ white-space: pre-wrap;
626
+ }
627
+
628
+ /* variant picker */
629
+ .variant-picker {
630
+ display: flex;
631
+ flex-direction: column;
632
+ gap: 8px;
633
+ }
634
+ .variant-picker-header {
635
+ display: flex;
636
+ align-items: baseline;
637
+ gap: 8px;
638
+ }
639
+ .variant-picker-header strong {
640
+ font-size: 13px;
641
+ }
642
+ .variant-picker-hint {
643
+ flex: 1;
644
+ font-size: 11px;
645
+ color: var(--md-sys-color-on-secondary-container, #777);
646
+ }
647
+ .regenerate-btn {
648
+ font-size: 12px;
649
+ padding: 4px 10px;
650
+ background: var(--md-sys-color-surface, #fff);
651
+ border: 1px solid var(--md-sys-color-outline-variant, #d0d7de);
652
+ border-radius: 4px;
653
+ cursor: pointer;
654
+ }
655
+ .regenerate-btn:hover {
656
+ background: var(--md-sys-color-surface-container, #f5f5f5);
657
+ }
658
+ .variant-grid {
659
+ display: grid;
660
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
661
+ gap: 10px;
662
+ }
663
+ .variant-card {
664
+ display: flex;
665
+ flex-direction: column;
666
+ gap: 4px;
667
+ padding: 4px;
668
+ border: 2px solid var(--md-sys-color-outline-variant, #e0e0e0);
669
+ border-radius: 8px;
670
+ background: var(--md-sys-color-surface, #fff);
671
+ cursor: pointer;
672
+ transition: border-color 0.12s, box-shadow 0.12s;
673
+ }
674
+ .variant-card:hover {
675
+ border-color: var(--md-sys-color-primary, #2a64d8);
676
+ }
677
+ .variant-card.selected {
678
+ border-color: var(--md-sys-color-primary, #2a64d8);
679
+ box-shadow: 0 0 0 2px var(--md-sys-color-primary-container, #eaf2ff);
680
+ }
681
+ .variant-thumb {
682
+ width: 100%;
683
+ height: 140px;
684
+ background: var(--md-sys-color-surface-container-lowest, #fafbfc);
685
+ border: 1px solid var(--md-sys-color-outline-variant, #e0e0e0);
686
+ border-radius: 4px;
687
+ overflow: hidden;
688
+ }
689
+ .variant-thumb ox-board-preview {
690
+ width: 100%;
691
+ height: 100%;
692
+ }
693
+ .variant-meta {
694
+ padding: 4px 6px 6px;
695
+ }
696
+ .variant-label {
697
+ font-size: 13px;
698
+ font-weight: 600;
699
+ }
700
+ .variant-desc {
701
+ font-size: 11px;
702
+ color: var(--md-sys-color-on-secondary-container, #777);
703
+ margin-top: 2px;
704
+ }
705
+ .variant-stats {
706
+ font-size: 11px;
707
+ color: var(--md-sys-color-on-secondary-container, #555);
708
+ margin-top: 4px;
709
+ }
710
+
711
+ /* big-preview — selected variant 큰 미리보기 */
712
+ .big-preview {
713
+ width: 100%;
714
+ height: 360px;
715
+ background: var(--md-sys-color-surface-container-lowest, #fafbfc);
716
+ border: 1px solid var(--md-sys-color-outline-variant, #e0e0e0);
717
+ border-radius: 8px;
718
+ overflow: hidden;
719
+ }
720
+ .big-preview ox-board-preview {
721
+ width: 100%;
722
+ height: 100%;
723
+ }
724
+
725
+ /* blank / template mode UI */
726
+ .mode-info {
727
+ display: flex;
728
+ flex-direction: column;
729
+ align-items: center;
730
+ gap: 8px;
731
+ padding: 32px 16px;
732
+ text-align: center;
733
+ }
734
+ .mode-info .icon {
735
+ font-size: 36px;
736
+ opacity: 0.6;
737
+ }
738
+ .mode-info .title {
739
+ font-size: 15px;
740
+ font-weight: 600;
741
+ }
742
+ .mode-info .desc {
743
+ font-size: 12px;
744
+ color: var(--md-sys-color-on-secondary-container, #777);
745
+ max-width: 480px;
746
+ }
747
+ .mode-info .primary {
748
+ margin-top: 8px;
749
+ }
750
+ .mode-hint {
751
+ padding: 10px 12px;
752
+ font-size: 12px;
753
+ color: var(--md-sys-color-on-secondary-container, #555);
754
+ background: var(--md-sys-color-surface-container-lowest, #fafbfc);
755
+ border-left: 3px solid var(--md-sys-color-primary, #2a64d8);
756
+ border-radius: 4px;
757
+ }
758
+
759
+ /* meta input — board type radios + group select */
760
+ .meta-input {
761
+ display: flex;
762
+ gap: 16px;
763
+ flex-wrap: wrap;
764
+ }
765
+ .meta-field {
766
+ display: flex;
767
+ flex-direction: column;
768
+ gap: 4px;
769
+ min-width: 180px;
770
+ }
771
+ .meta-field label {
772
+ font-size: 12px;
773
+ font-weight: 500;
774
+ text-transform: capitalize;
775
+ }
776
+ .meta-field select {
777
+ padding: 6px 8px;
778
+ font-size: 13px;
779
+ border: 1px solid var(--md-sys-color-outline-variant, #d0d7de);
780
+ border-radius: 6px;
781
+ }
782
+ .board-type-radios {
783
+ display: flex;
784
+ gap: 8px;
785
+ align-items: center;
786
+ }
787
+ .radio-label {
788
+ display: inline-flex;
789
+ align-items: center;
790
+ gap: 4px;
791
+ font-size: 13px;
792
+ }
793
+
794
+ .quick-form .controls {
795
+ margin-top: auto;
796
+ }
797
+ .name-input {
798
+ display: flex;
799
+ flex-direction: column;
800
+ gap: 4px;
801
+ }
802
+ .name-input label {
803
+ font-size: 12px;
804
+ font-weight: 500;
805
+ text-transform: capitalize;
806
+ }
807
+ .name-input input {
808
+ padding: 8px 10px;
809
+ font-size: 14px;
810
+ border: 1px solid var(--md-sys-color-outline-variant, #d0d7de);
811
+ border-radius: 6px;
812
+ outline: none;
813
+ }
814
+ .name-input input:focus {
815
+ border-color: var(--md-sys-color-primary, #2a64d8);
816
+ }
817
+
818
+ .description-input {
819
+ display: flex;
820
+ flex-direction: column;
821
+ gap: 4px;
822
+ }
823
+ .description-input label {
824
+ display: flex;
825
+ flex-direction: column;
826
+ gap: 2px;
827
+ font-size: 12px;
828
+ font-weight: 500;
829
+ text-transform: capitalize;
830
+ }
831
+ .description-input label .hint {
832
+ font-size: 11px;
833
+ font-weight: 400;
834
+ color: var(--md-sys-color-on-secondary-container, #777);
835
+ }
836
+ .description-input textarea {
837
+ font-family: inherit;
838
+ font-size: 13px;
839
+ line-height: 1.5;
840
+ padding: 8px 10px;
841
+ border: 1px solid var(--md-sys-color-outline-variant, #d0d7de);
842
+ border-radius: 6px;
843
+ outline: none;
844
+ resize: vertical;
845
+ min-height: 90px;
846
+ }
847
+ .description-input textarea:focus {
848
+ border-color: var(--md-sys-color-primary, #2a64d8);
849
+ }
850
+
851
+ /* done step */
852
+ .done-card {
853
+ text-align: center;
854
+ padding: 32px 24px;
855
+ background: var(--md-sys-color-tertiary-container, #e8f5e9);
856
+ border-radius: 12px;
857
+ }
858
+ .done-card .icon {
859
+ font-size: 48px;
860
+ --md-icon-size: 48px;
861
+ color: var(--md-sys-color-tertiary, #2e7d32);
862
+ }
863
+ .done-card .title {
864
+ font-size: 18px;
865
+ font-weight: 600;
866
+ margin-top: 8px;
867
+ }
868
+ .done-card .meta {
869
+ font-size: 13px;
870
+ color: var(--md-sys-color-on-secondary-container, #555);
871
+ margin-top: 4px;
872
+ }
873
+
874
+ button.primary {
875
+ padding: 8px 16px;
876
+ background: var(--md-sys-color-primary, #2a64d8);
877
+ color: white;
878
+ border: none;
879
+ border-radius: 6px;
880
+ font-weight: 500;
881
+ cursor: pointer;
882
+ text-transform: capitalize;
883
+ }
884
+ button.primary:disabled {
885
+ opacity: 0.5;
886
+ cursor: not-allowed;
887
+ }
888
+ button.secondary {
889
+ padding: 8px 16px;
890
+ background: transparent;
891
+ color: var(--md-sys-color-on-surface, #333);
892
+ border: 1px solid var(--md-sys-color-outline-variant, #d0d7de);
893
+ border-radius: 6px;
894
+ cursor: pointer;
895
+ text-transform: capitalize;
896
+ }
897
+ `
898
+ ]; }
899
+ /** wizard 제목 / 설명 — page header 에 노출. */
900
+ get context() {
901
+ return {
902
+ title: i18next.t('title.create board') || 'Create Board',
903
+ board_topmenu: false
904
+ };
905
+ }
906
+ render() {
907
+ return html `
908
+ <div class="header">
909
+ <h1>${i18next.t('title.create board') || 'Create Board'}</h1>
910
+ </div>
911
+
912
+ ${this._renderModeTabs()}
913
+
914
+ <div class="stage">
915
+ ${this._uploadMode === 'blank'
916
+ ? this._renderFromScratchTab()
917
+ : this._uploadMode === 'template'
918
+ ? this._renderFromTemplateTab()
919
+ : this._renderFileWizard()}
920
+ </div>
921
+ `;
922
+ }
923
+ _renderModeTabs() {
924
+ // 파일 위자드가 upload 단계를 지나면 탭 전환 불가 (진행 중 이탈 방지)
925
+ const locked = this._isFileMode() && this._step !== 'upload';
926
+ return html `
927
+ <div class="mode-tabs ${locked ? 'locked' : ''}">
928
+ <button
929
+ class="mode-tab ${this._uploadMode === 'blank' ? 'active' : ''}"
930
+ ?disabled=${locked}
931
+ @click=${() => this._setUploadMode('blank')}
932
+ >
933
+ ${i18next.t('label.from-scratch') || 'From Scratch'}
934
+ </button>
935
+ <button
936
+ class="mode-tab ${this._uploadMode === 'template' ? 'active' : ''}"
937
+ ?disabled=${locked}
938
+ @click=${() => this._setUploadMode('template')}
939
+ >
940
+ ${i18next.t('label.from-template') || 'From Template'}
941
+ </button>
942
+ <button
943
+ class="mode-tab ${this._uploadMode === 'new' ? 'active' : ''}"
944
+ ?disabled=${locked}
945
+ @click=${() => this._setUploadMode('new')}
946
+ >
947
+ ${i18next.t('label.from-new-drawing') || 'From New Drawing'}
948
+ </button>
949
+ <button
950
+ class="mode-tab ${this._uploadMode === 'existing' ? 'active' : ''}"
951
+ ?disabled=${locked}
952
+ @click=${() => this._setUploadMode('existing')}
953
+ >
954
+ ${i18next.t('label.from-existing-drawing') || 'From Existing Drawing'}
955
+ </button>
956
+ </div>
957
+ `;
958
+ }
959
+ _renderFileWizard() {
960
+ return html `
961
+ ${this._renderStepper()}
962
+ ${this._step === 'upload' ? this._renderFileUploadUI() : ''}
963
+ ${this._step === 'progress' ? this._renderProgressStep() : ''}
964
+ ${this._step === 'review' ? this._renderReviewStep() : ''}
965
+ ${this._step === 'done' ? this._renderDoneStep() : ''}
966
+ `;
967
+ }
968
+ _renderStepper() {
969
+ const firstLabel = this._uploadMode === 'existing'
970
+ ? i18next.t('label.select file') || 'Select File'
971
+ : i18next.t('label.upload') || 'Upload';
972
+ const steps = [
973
+ { key: 'upload', label: firstLabel },
974
+ { key: 'progress', label: i18next.t('label.import progress') || 'Conversion Progress' },
975
+ { key: 'review', label: i18next.t('label.review') || 'Review' },
976
+ { key: 'done', label: i18next.t('label.done') || 'Done' }
977
+ ];
978
+ const activeIndex = steps.findIndex(s => s.key === this._step);
979
+ return html `
980
+ <div class="stepper">
981
+ ${steps.map((s, i) => {
982
+ const cls = i < activeIndex ? 'step done' : i === activeIndex ? 'step active' : 'step';
983
+ return html `
984
+ ${i > 0 ? html `<span class="arrow">›</span>` : ''}
985
+ <span class=${cls}>
986
+ <span class="index">${i + 1}</span>
987
+ ${s.label}
988
+ </span>
989
+ `;
990
+ })}
991
+ </div>
992
+ `;
993
+ }
994
+ // ── upload step (파일 모드) ──────────────────────────────────────
995
+ _renderFileUploadUI() {
996
+ return html `
997
+ ${this._uploadError ? html `<div class="error-banner">${this._uploadError}</div>` : ''}
998
+ ${this._uploadMode === 'new' ? this._renderNewUpload() : this._renderBrowseExisting()}
999
+ ${this._attachment ? this._renderSelectedPreview() : ''}
1000
+ `;
1001
+ }
1002
+ /** "처음부터" 탭 — 빈 보드 생성 전용 단순 폼. 템플릿 없음. */
1003
+ _renderFromScratchTab() {
1004
+ return html `
1005
+ <div class="quick-form">
1006
+ ${this._renderBoardForm('blank')}
1007
+ </div>
1008
+ `;
1009
+ }
1010
+ /** "템플릿" 탭 — existing files와 동일한 browse-item 카드 스타일. */
1011
+ _renderFromTemplateTab() {
1012
+ const filtered = this._templateSearch
1013
+ ? this._templates.filter(t => t.name.toLowerCase().includes(this._templateSearch.toLowerCase()) ||
1014
+ (t.description || '').toLowerCase().includes(this._templateSearch.toLowerCase()))
1015
+ : this._templates;
1016
+ return html `
1017
+ <div class="template-layout">
1018
+ <div>
1019
+ <div class="browse-toolbar">
1020
+ <input
1021
+ class="browse-search"
1022
+ type="search"
1023
+ placeholder=${i18next.t('placeholder.search template') || 'Search templates'}
1024
+ .value=${this._templateSearch}
1025
+ @input=${(e) => (this._templateSearch = e.target.value)}
1026
+ />
1027
+ <button
1028
+ class="secondary"
1029
+ @click=${() => this._loadTemplates(true)}
1030
+ ?disabled=${this._templatesLoading}
1031
+ >
1032
+ ${i18next.t('button.refresh') || 'Refresh'}
1033
+ </button>
1034
+ </div>
1035
+
1036
+ ${this._templatesLoading
1037
+ ? html `<div class="browse-empty">${i18next.t('text.loading') || 'Loading...'}</div>`
1038
+ : filtered.length === 0
1039
+ ? html `<div class="browse-empty">
1040
+ ${i18next.t('text.no templates') || 'No templates available.'}
1041
+ </div>`
1042
+ : html `
1043
+ <div class="browse-list">
1044
+ ${filtered.map(t => html `
1045
+ <div
1046
+ class="browse-item ${this._selectedTemplate?.id === t.id ? 'selected' : ''}"
1047
+ title=${t.name}
1048
+ @click=${() => this._selectTemplate(t.id)}
1049
+ >
1050
+ ${t.thumbnail
1051
+ ? html `<img class="thumb" src=${t.thumbnail} />`
1052
+ : html `<div class="thumb-fallback">BOARD</div>`}
1053
+ <div class="name">${t.name}</div>
1054
+ ${t.description
1055
+ ? html `<div class="meta">${t.description}</div>`
1056
+ : ''}
1057
+ </div>
1058
+ `)}
1059
+ </div>
1060
+ `}
1061
+ </div>
1062
+
1063
+ <div class="quick-form">
1064
+ ${this._renderBoardForm('template')}
1065
+ </div>
1066
+ </div>
1067
+ `;
1068
+ }
1069
+ /** 공통 보드 생성 폼 (blank / template 모드 공유). */
1070
+ _renderBoardForm(mode) {
1071
+ const canSubmit = !!this._effectiveName() && (mode === 'blank' || !!this._selectedTemplate?.model);
1072
+ return html `
1073
+ <div class="name-input">
1074
+ <label for="board-name">
1075
+ ${i18next.t('label.new board name') || 'New Board Name'} *
1076
+ </label>
1077
+ <input
1078
+ id="board-name"
1079
+ type="text"
1080
+ placeholder=${this._selectedTemplate?.name ?? (i18next.t('placeholder.board name') || 'New Board')}
1081
+ .value=${this._newBoardName}
1082
+ @input=${(e) => (this._newBoardName = e.target.value)}
1083
+ ?disabled=${this._materializing}
1084
+ />
1085
+ </div>
1086
+
1087
+ <div class="description-input">
1088
+ <label for="board-description">
1089
+ ${i18next.t('label.board description') || 'Board Description'}
1090
+ </label>
1091
+ <textarea
1092
+ id="board-description"
1093
+ .value=${this._newBoardDescription}
1094
+ @input=${(e) => (this._newBoardDescription = e.target.value)}
1095
+ ?disabled=${this._materializing}
1096
+ placeholder=${i18next.t('placeholder.board description') || 'Detailed description (optional)'}
1097
+ ></textarea>
1098
+ </div>
1099
+
1100
+ <div class="meta-input">
1101
+ <div class="meta-field">
1102
+ <label>${i18next.t('label.board-type') || 'Board Type'}</label>
1103
+ <div class="board-type-radios">
1104
+ ${['main', 'sub', 'popup'].map(t => html `
1105
+ <label class="radio-label">
1106
+ <input
1107
+ type="radio"
1108
+ name="quick-board-type"
1109
+ value=${t}
1110
+ .checked=${this._selectedBoardType === t}
1111
+ @change=${() => (this._selectedBoardType = t)}
1112
+ />
1113
+ ${t}
1114
+ </label>
1115
+ `)}
1116
+ </div>
1117
+ </div>
1118
+ <div class="meta-field">
1119
+ <label for="quick-group">${i18next.t('label.group') || 'Group'}</label>
1120
+ <select
1121
+ id="quick-group"
1122
+ .value=${this._selectedGroupId}
1123
+ @change=${(e) => (this._selectedGroupId = e.target.value)}
1124
+ ?disabled=${this._materializing}
1125
+ >
1126
+ <option value="">${i18next.t('label.no group') || '(No Group)'}</option>
1127
+ ${this._groups.map(g => html `<option value=${g.id}>${g.name}</option>`)}
1128
+ </select>
1129
+ </div>
1130
+ </div>
1131
+
1132
+ ${this._materializeError
1133
+ ? html `<div class="error-banner">${this._materializeError}</div>`
1134
+ : ''}
1135
+
1136
+ <div class="controls">
1137
+ <button
1138
+ class="primary"
1139
+ @click=${this._materialize}
1140
+ ?disabled=${this._materializing || !canSubmit}
1141
+ >
1142
+ ${this._materializing
1143
+ ? i18next.t('text.saving') || 'Saving...'
1144
+ : i18next.t('button.create board') || 'Create Board'}
1145
+ </button>
1146
+ </div>
1147
+ `;
1148
+ }
1149
+ _renderNewUpload() {
1150
+ return html `
1151
+ <label
1152
+ class="drop-zone"
1153
+ @dragover=${(e) => {
1154
+ e.preventDefault();
1155
+ e.currentTarget.classList.add('dragover');
1156
+ }}
1157
+ @dragleave=${(e) => {
1158
+ ;
1159
+ e.currentTarget.classList.remove('dragover');
1160
+ }}
1161
+ @drop=${this._onDrop}
1162
+ >
1163
+ <md-icon class="icon">upload</md-icon>
1164
+ <div class="title">${this._uploading ? i18next.t('text.uploading') || 'Uploading...' : i18next.t('text.drop or click to upload') || 'Drop a drawing here or click to browse'}</div>
1165
+ <div class="hint">${ACCEPT}</div>
1166
+ <input
1167
+ type="file"
1168
+ accept=${ACCEPT}
1169
+ ?disabled=${this._uploading}
1170
+ @change=${this._onFileChange}
1171
+ />
1172
+ </label>
1173
+ `;
1174
+ }
1175
+ _renderBrowseExisting() {
1176
+ return html `
1177
+ <div class="browse-toolbar">
1178
+ <input
1179
+ class="browse-search"
1180
+ type="search"
1181
+ placeholder=${i18next.t('placeholder.search file') || 'Search by filename'}
1182
+ .value=${this._browseSearch}
1183
+ @input=${(e) => {
1184
+ this._browseSearch = e.target.value;
1185
+ this._refreshBrowse();
1186
+ }}
1187
+ />
1188
+ <button
1189
+ class="secondary"
1190
+ @click=${() => this._refreshBrowse()}
1191
+ ?disabled=${this._browseLoading}
1192
+ >
1193
+ ${i18next.t('button.refresh') || 'Refresh'}
1194
+ </button>
1195
+ </div>
1196
+
1197
+ ${this._browseLoading
1198
+ ? html `<div class="browse-empty">${i18next.t('text.loading') || 'Loading...'}</div>`
1199
+ : this._browseItems.length === 0
1200
+ ? html `<div class="browse-empty">
1201
+ ${i18next.t('text.no existing files') ||
1202
+ 'No drawing or image files found. Use the \'From New Drawing\' tab to upload.'}
1203
+ </div>`
1204
+ : html `
1205
+ <div class="browse-list">
1206
+ ${this._browseItems.map(item => html `
1207
+ <div
1208
+ class="browse-item"
1209
+ title=${item.name}
1210
+ @click=${() => this._selectExisting(item)}
1211
+ >
1212
+ ${this._isImageMime(item.mimetype)
1213
+ ? html `<img class="thumb" src="/attachment/${item.path}" />`
1214
+ : html `<div class="thumb-fallback">
1215
+ ${(item.mimetype?.split('/')[1] || 'file').toUpperCase()}
1216
+ </div>`}
1217
+ <div class="name">${item.name}</div>
1218
+ <div class="meta">
1219
+ ${item.size ? this._formatSize(item.size) : ''}
1220
+ ${item.mimetype ? ' · ' + item.mimetype : ''}
1221
+ </div>
1222
+ </div>
1223
+ `)}
1224
+ </div>
1225
+ `}
1226
+ `;
1227
+ }
1228
+ /**
1229
+ * 선택된 attachment 의 미리보기 + 변환 시작 버튼. 두 모드 (new/existing) 공통 렌더링.
1230
+ * 이미지 mimetype 이면 실제 이미지를 띄우고, 아니면 메타정보 카드만.
1231
+ * 추가로 사용자 prompt textarea — VLM 의 view type 분류 + entity 추출에 hint 로 합류.
1232
+ */
1233
+ _renderSelectedPreview() {
1234
+ if (!this._attachment)
1235
+ return '';
1236
+ const a = this._attachment;
1237
+ const isImage = this._isImageMime(a.mimetype);
1238
+ return html `
1239
+ <div class="preview-pane">
1240
+ ${isImage
1241
+ ? html `<img class="preview-image" src="/attachment/${a.path}" />`
1242
+ : html `<div class="preview-fallback">
1243
+ ${(a.mimetype || 'file').toUpperCase()}
1244
+ </div>`}
1245
+ <div class="info-row">
1246
+ <span class="label">${i18next.t('label.name') || 'Name'}</span>
1247
+ <span>${a.name}</span>
1248
+ </div>
1249
+ <div class="info-row">
1250
+ <span class="label">${i18next.t('label.mimetype') || 'MIME'}</span>
1251
+ <span>${a.mimetype || 'unknown'}</span>
1252
+ </div>
1253
+ <div class="info-row">
1254
+ <span class="label">${i18next.t('label.size') || 'Size'}</span>
1255
+ <span>${a.size ? this._formatSize(a.size) : '-'}</span>
1256
+ </div>
1257
+ </div>
1258
+
1259
+ <div class="user-prompt">
1260
+ <label>
1261
+ ${i18next.t('label.user prompt') || 'Drawing notes / key features'}
1262
+ <span class="hint">
1263
+ ${i18next.t('hint.user prompt') ||
1264
+ 'Optional — e.g. "FAB lithography zone, large rectangles are stockers, thin lines are OHT rails"'}
1265
+ </span>
1266
+ </label>
1267
+ <textarea
1268
+ rows="3"
1269
+ .value=${this._userPrompt}
1270
+ @input=${(e) => (this._userPrompt = e.target.value)}
1271
+ ?disabled=${this._uploading}
1272
+ placeholder=${i18next.t('placeholder.user prompt') ||
1273
+ 'Add notes about this drawing (optional)'}
1274
+ ></textarea>
1275
+ </div>
1276
+
1277
+ <div class="controls">
1278
+ <button class="secondary" @click=${this._resetUpload} ?disabled=${this._uploading}>
1279
+ ${i18next.t('button.choose another') || 'Choose Another File'}
1280
+ </button>
1281
+ <button class="primary" @click=${this._proceedToImport} ?disabled=${this._uploading}>
1282
+ ${i18next.t('button.start import') || 'Start Conversion'}
1283
+ </button>
1284
+ </div>
1285
+ `;
1286
+ }
1287
+ _isImageMime(mime) {
1288
+ return !!mime && mime.toLowerCase().startsWith('image/');
1289
+ }
1290
+ async _setUploadMode(mode) {
1291
+ this._uploadMode = mode;
1292
+ if (mode === 'existing' && this._browseItems.length === 0 && !this._browseLoading) {
1293
+ await this._refreshBrowse();
1294
+ }
1295
+ if (mode === 'template' && !this._templatesLoaded && !this._templatesLoading) {
1296
+ await this._loadTemplates();
1297
+ }
1298
+ // blank/template 진입 시 group 목록 lazy fetch (review step 의 group selector 용)
1299
+ if ((mode === 'blank' || mode === 'template') && !this._groupsLoaded) {
1300
+ await this._loadGroups();
1301
+ }
1302
+ }
1303
+ async _loadGroups() {
1304
+ try {
1305
+ const res = await fetchGroupList();
1306
+ const items = res?.groups?.items;
1307
+ this._groups = Array.isArray(items) ? items : [];
1308
+ }
1309
+ catch (err) {
1310
+ console.warn('[board-create-wizard] fetchGroupList failed', err);
1311
+ this._groups = [];
1312
+ }
1313
+ this._groupsLoaded = true;
1314
+ }
1315
+ async _loadTemplates(force = false) {
1316
+ if (this._templatesLoaded && !force)
1317
+ return;
1318
+ this._templatesLoading = true;
1319
+ try {
1320
+ const data = await fetchBoardTemplateList({ pagination: { limit: 100, page: 1 } });
1321
+ this._templates = data?.boardTemplates?.items || [];
1322
+ this._templatesLoaded = true;
1323
+ }
1324
+ catch (err) {
1325
+ console.warn('[board-create-wizard] fetchBoardTemplateList failed', err);
1326
+ this._templates = [];
1327
+ }
1328
+ this._templatesLoading = false;
1329
+ }
1330
+ async _selectTemplate(id) {
1331
+ const data = await fetchBoardTemplate(id);
1332
+ let template = data?.boardTemplate;
1333
+ if (template?.model)
1334
+ template = { ...template, model: JSON.parse(template.model) };
1335
+ this._selectedTemplate = template || undefined;
1336
+ if (template?.name && !this._newBoardName) {
1337
+ this._newBoardName = template.name;
1338
+ }
1339
+ }
1340
+ /**
1341
+ * 기존 attachment 목록 fetch — 이미지 + DXF 만 (mimetype prefix 'image' 1차 + 검색은 파일명).
1342
+ * mimetype 단일 prefix 만 지원하는 백엔드 한계로 image / application 분리 query 가 필요하지만,
1343
+ * MVP 는 두 번 fetch 해서 합치는 단순 방식.
1344
+ */
1345
+ async _refreshBrowse() {
1346
+ this._browseLoading = true;
1347
+ try {
1348
+ const [imgs, others] = await Promise.all([
1349
+ fetchAttachmentsForImport({
1350
+ search: this._browseSearch || undefined,
1351
+ mimetypePrefix: 'image',
1352
+ limit: 30
1353
+ }),
1354
+ fetchAttachmentsForImport({
1355
+ search: (this._browseSearch || '') + '%.dxf',
1356
+ limit: 20
1357
+ })
1358
+ ]);
1359
+ // 단순 합치기 — image first, then DXF, dedupe by id
1360
+ const seen = new Set();
1361
+ const merged = [];
1362
+ for (const it of [...imgs.items, ...others.items]) {
1363
+ if (!seen.has(it.id)) {
1364
+ seen.add(it.id);
1365
+ merged.push(it);
1366
+ }
1367
+ }
1368
+ this._browseItems = merged;
1369
+ }
1370
+ catch (err) {
1371
+ notify({ level: 'error', message: err?.message || String(err) });
1372
+ this._browseItems = [];
1373
+ }
1374
+ finally {
1375
+ this._browseLoading = false;
1376
+ }
1377
+ }
1378
+ _selectExisting(item) {
1379
+ this._attachment = item;
1380
+ this._uploadError = undefined;
1381
+ }
1382
+ async _onFileChange(e) {
1383
+ const input = e.target;
1384
+ const file = input.files?.[0];
1385
+ if (file)
1386
+ await this._uploadFile(file);
1387
+ // value 초기화 — 같은 파일 재선택 가능
1388
+ input.value = '';
1389
+ }
1390
+ async _onDrop(e) {
1391
+ e.preventDefault();
1392
+ e.currentTarget.classList.remove('dragover');
1393
+ const file = e.dataTransfer?.files?.[0];
1394
+ if (file)
1395
+ await this._uploadFile(file);
1396
+ }
1397
+ async _uploadFile(file) {
1398
+ this._uploading = true;
1399
+ this._uploadError = undefined;
1400
+ try {
1401
+ const result = await createAttachmentForImport(file);
1402
+ this._attachment = result;
1403
+ }
1404
+ catch (err) {
1405
+ const message = err?.message || String(err);
1406
+ this._uploadError = message;
1407
+ notify({ level: 'error', message });
1408
+ }
1409
+ finally {
1410
+ this._uploading = false;
1411
+ }
1412
+ }
1413
+ _resetUpload() {
1414
+ this._attachment = undefined;
1415
+ this._uploadError = undefined;
1416
+ }
1417
+ async _proceedToImport() {
1418
+ if (!this._attachment)
1419
+ return;
1420
+ this._step = 'progress';
1421
+ this._importError = undefined;
1422
+ this._session = undefined;
1423
+ try {
1424
+ const session = await importBoardAsync({
1425
+ attachmentId: this._attachment.id,
1426
+ userPrompt: this._userPrompt.trim() || undefined
1427
+ });
1428
+ this._session = session;
1429
+ this._startPolling();
1430
+ }
1431
+ catch (err) {
1432
+ const message = err?.message || String(err);
1433
+ this._importError = message;
1434
+ notify({ level: 'error', message });
1435
+ }
1436
+ }
1437
+ // ── progress step ───────────────────────────────────────────────
1438
+ /**
1439
+ * polling — 1초 간격으로 fetchImportSession.
1440
+ * - status='completed' → review step
1441
+ * - status='failed' → error 표시 + 재시도 가능
1442
+ * - 그 외 → progress UI 갱신 후 다시 polling
1443
+ *
1444
+ * 취소 시점: pollingTimer 명시 clear (다음 단계 진입 / 페이지 떠남).
1445
+ */
1446
+ _startPolling() {
1447
+ this._stopPolling();
1448
+ const tick = async () => {
1449
+ if (!this._session?.id)
1450
+ return;
1451
+ try {
1452
+ const fresh = await fetchImportSession(this._session.id);
1453
+ this._session = fresh;
1454
+ if (fresh?.status === 'completed') {
1455
+ this._stopPolling();
1456
+ this._step = 'review';
1457
+ // 보드 이름 자동 추천 — 충돌 회피된 이름을 서버에서 받아서 input default 로.
1458
+ // 이미 사용자가 수정한 값이 있으면 덮어쓰지 않음.
1459
+ if (!this._newBoardName && !this._newBoardDescription) {
1460
+ try {
1461
+ const meta = await suggestBoardMeta({ sessionId: fresh.id });
1462
+ // 사용자가 이미 수정한 값 보존 — name/description 각각 독립 체크
1463
+ if (meta?.name && !this._newBoardName)
1464
+ this._newBoardName = meta.name;
1465
+ if (meta?.description && !this._newBoardDescription) {
1466
+ this._newBoardDescription = meta.description;
1467
+ }
1468
+ }
1469
+ catch (err) {
1470
+ console.warn('[board-create-wizard] suggestBoardMeta failed', err);
1471
+ }
1472
+ }
1473
+ return;
1474
+ }
1475
+ if (fresh?.status === 'failed') {
1476
+ this._stopPolling();
1477
+ this._importError = fresh.message || 'Import failed';
1478
+ return;
1479
+ }
1480
+ }
1481
+ catch (err) {
1482
+ // polling 일시적 에러는 무시 — 다음 tick 에 재시도. 단 너무 자주면 사용자에 노출.
1483
+ console.warn('[board-create-wizard] polling error', err);
1484
+ }
1485
+ this._pollingTimer = window.setTimeout(tick, 1000);
1486
+ };
1487
+ // 즉시 1회 + 이후 setTimeout chain
1488
+ this._pollingTimer = window.setTimeout(tick, 0);
1489
+ }
1490
+ _stopPolling() {
1491
+ if (this._pollingTimer) {
1492
+ clearTimeout(this._pollingTimer);
1493
+ this._pollingTimer = undefined;
1494
+ }
1495
+ }
1496
+ _renderProgressStep() {
1497
+ if (this._importError) {
1498
+ return html `
1499
+ <div class="error-banner">${this._importError}</div>
1500
+ <div class="controls">
1501
+ <button class="secondary" @click=${this._retryFromUpload}>
1502
+ ${i18next.t('button.retry') || 'Retry'}
1503
+ </button>
1504
+ </div>
1505
+ `;
1506
+ }
1507
+ const session = this._session;
1508
+ const progress = session?.progress ?? 0;
1509
+ const status = session?.status ?? 'queued';
1510
+ const message = session?.message ?? '';
1511
+ return html `
1512
+ <div class="progress-card">
1513
+ <div class="progress-status">
1514
+ <span class="label">${this._statusLabel(status)}</span>
1515
+ <span class="pct">${Math.round(progress)}%</span>
1516
+ </div>
1517
+ <div class="progress-bar">
1518
+ <div class="fill" style="width: ${Math.min(100, Math.max(0, progress))}%"></div>
1519
+ </div>
1520
+ ${message ? html `<div class="progress-message">${message}</div>` : ''}
1521
+ </div>
1522
+ `;
1523
+ }
1524
+ _statusLabel(status) {
1525
+ switch (status) {
1526
+ case 'queued': return i18next.t('label.queued') || 'Queued';
1527
+ case 'parsing': return i18next.t('label.parsing') || 'Parsing';
1528
+ case 'mapping': return i18next.t('label.mapping') || 'Mapping';
1529
+ case 'assembling': return i18next.t('label.assembling') || 'Assembling Board';
1530
+ case 'binding': return i18next.t('label.binding') || 'Data Binding';
1531
+ case 'completed': return i18next.t('label.completed') || 'Completed';
1532
+ case 'failed': return i18next.t('label.failed') || 'Failed';
1533
+ default: return status;
1534
+ }
1535
+ }
1536
+ _retryFromUpload() {
1537
+ this._stopPolling();
1538
+ this._session = undefined;
1539
+ this._importError = undefined;
1540
+ this._step = 'upload';
1541
+ }
1542
+ disconnectedCallback() {
1543
+ this._stopPolling();
1544
+ super.disconnectedCallback();
1545
+ }
1546
+ /**
1547
+ * step 이나 session 변경 시 polling 상태 동기화. step='progress' + session 존재 + 종료 안된
1548
+ * 상태에서만 polling 활성화. 그 외 상태로 진입하면 자동 정리.
1549
+ */
1550
+ updated(changes) {
1551
+ super.updated(changes);
1552
+ if (changes.has('_step') || changes.has('_session')) {
1553
+ const session = this._session;
1554
+ const inProgress = this._step === 'progress';
1555
+ const sessionAlive = session && session.status !== 'completed' && session.status !== 'failed';
1556
+ if (inProgress && sessionAlive && !this._pollingTimer) {
1557
+ this._startPolling();
1558
+ }
1559
+ else if (!inProgress || !sessionAlive) {
1560
+ this._stopPolling();
1561
+ }
1562
+ }
1563
+ }
1564
+ // ── review step ─────────────────────────────────────────────────
1565
+ _renderReviewStep() {
1566
+ return html `
1567
+ ${this._materializeError
1568
+ ? html `<div class="error-banner">${this._materializeError}</div>`
1569
+ : ''}
1570
+
1571
+ ${this._renderFileReviewBody()}
1572
+
1573
+ <div class="name-input">
1574
+ <label for="board-name">${i18next.t('label.new board name') || 'New Board Name'}</label>
1575
+ <input
1576
+ id="board-name"
1577
+ type="text"
1578
+ placeholder=${this._attachment?.name?.replace(/\.[^.]+$/, '') ??
1579
+ this._selectedTemplate?.name ?? (i18next.t('placeholder.board name') || 'New Board')}
1580
+ .value=${this._newBoardName}
1581
+ @input=${(e) => (this._newBoardName = e.target.value)}
1582
+ ?disabled=${this._materializing}
1583
+ />
1584
+ </div>
1585
+
1586
+ <div class="description-input">
1587
+ <label for="board-description">
1588
+ ${i18next.t('label.board description') || 'Board Description'}
1589
+ </label>
1590
+ <textarea
1591
+ id="board-description"
1592
+ rows="3"
1593
+ .value=${this._newBoardDescription}
1594
+ @input=${(e) => (this._newBoardDescription = e.target.value)}
1595
+ ?disabled=${this._materializing}
1596
+ placeholder=${i18next.t('placeholder.board description') ||
1597
+ 'Detailed description (optional)'}
1598
+ ></textarea>
1599
+ </div>
1600
+
1601
+ <div class="meta-input">
1602
+ <div class="meta-field">
1603
+ <label>${i18next.t('label.board-type') || 'Board Type'}</label>
1604
+ <div class="board-type-radios">
1605
+ ${['main', 'sub', 'popup'].map(t => html `
1606
+ <label class="radio-label">
1607
+ <input
1608
+ type="radio"
1609
+ name="board-type"
1610
+ value=${t}
1611
+ .checked=${this._selectedBoardType === t}
1612
+ @change=${() => (this._selectedBoardType = t)}
1613
+ />
1614
+ ${t}
1615
+ </label>
1616
+ `)}
1617
+ </div>
1618
+ </div>
1619
+ <div class="meta-field">
1620
+ <label for="board-group">${i18next.t('label.group') || 'Group'}</label>
1621
+ <select
1622
+ id="board-group"
1623
+ .value=${this._selectedGroupId}
1624
+ @change=${(e) => (this._selectedGroupId = e.target.value)}
1625
+ ?disabled=${this._materializing}
1626
+ >
1627
+ <option value="">
1628
+ ${i18next.t('label.no group') || '(No Group)'}
1629
+ </option>
1630
+ ${this._groups.map(g => html `<option value=${g.id}>${g.name}</option>`)}
1631
+ </select>
1632
+ </div>
1633
+ </div>
1634
+
1635
+ <div class="controls">
1636
+ <button class="secondary" @click=${this._retryFromUpload} ?disabled=${this._materializing}>
1637
+ ${i18next.t('button.start over') || 'Start Over'}
1638
+ </button>
1639
+ <button
1640
+ class="primary"
1641
+ @click=${this._materialize}
1642
+ ?disabled=${this._materializing || !this._effectiveName()}
1643
+ >
1644
+ ${this._materializing
1645
+ ? i18next.t('text.saving') || 'Saving...'
1646
+ : i18next.t('button.save as new board') || 'Save as New Board'}
1647
+ </button>
1648
+ </div>
1649
+ `;
1650
+ }
1651
+ /** 파일 import 모드 여부 — true 면 variants/AI 분석 표시, false 면 단순 정보 입력. */
1652
+ _isFileMode() {
1653
+ return this._uploadMode === 'new' || this._uploadMode === 'existing';
1654
+ }
1655
+ /** review 의 파일 모드 본문 — variants picker + AI 분석 + 큰 preview + stats + warnings. */
1656
+ _renderFileReviewBody() {
1657
+ const result = this._session?.result;
1658
+ const variants = Array.isArray(result?.variants) ? result.variants : [];
1659
+ const selected = this._getSelectedVariant(variants, result);
1660
+ const stats = selected?.stats ?? result?.stats ?? {};
1661
+ const warnings = (selected?.warnings ?? result?.warnings ?? []).map((w) => typeof w === 'string' ? w : w.message ?? JSON.stringify(w));
1662
+ return html `
1663
+ ${variants.length > 1 ? this._renderVariantPicker(variants, selected) : ''}
1664
+ ${this._renderAISummary(result, selected)}
1665
+
1666
+ ${selected?.boardModel
1667
+ ? html `
1668
+ <div class="big-preview">
1669
+ <ox-board-preview
1670
+ .boardModel=${selected.boardModel}
1671
+ interactive
1672
+ ></ox-board-preview>
1673
+ </div>
1674
+ `
1675
+ : ''}
1676
+
1677
+ <div class="review-stats">
1678
+ <div class="stat">
1679
+ <span class="label">${i18next.t('label.entities') || 'Entities'}</span>
1680
+ <span class="value">${stats.total ?? '?'}</span>
1681
+ </div>
1682
+ <div class="stat">
1683
+ <span class="label">${i18next.t('label.matched') || 'Matched'}</span>
1684
+ <span class="value">${stats.matched ?? 0}</span>
1685
+ </div>
1686
+ <div class="stat">
1687
+ <span class="label">${i18next.t('label.generic') || 'Generic'}</span>
1688
+ <span class="value">${stats.generic ?? 0}</span>
1689
+ </div>
1690
+ <div class="stat">
1691
+ <span class="label">${i18next.t('label.warnings') || 'Warnings'}</span>
1692
+ <span class="value">${warnings.length}</span>
1693
+ </div>
1694
+ </div>
1695
+
1696
+ ${warnings.length > 0
1697
+ ? html `
1698
+ <div class="warnings">
1699
+ <strong>${i18next.t('label.warnings') || 'Warnings'}:</strong>
1700
+ <ul>
1701
+ ${warnings.slice(0, 20).map(w => html `<li>${w}</li>`)}
1702
+ ${warnings.length > 20
1703
+ ? html `<li>… +${warnings.length - 20} more</li>`
1704
+ : ''}
1705
+ </ul>
1706
+ </div>
1707
+ `
1708
+ : ''}
1709
+ `;
1710
+ }
1711
+ /**
1712
+ * 사용자가 선택한 시안 — _selectedVariantId 우선, 없으면 result.defaultVariantId, 없으면 첫 시안.
1713
+ */
1714
+ _getSelectedVariant(variants, result) {
1715
+ if (variants.length === 0)
1716
+ return undefined;
1717
+ if (this._selectedVariantId) {
1718
+ const found = variants.find(v => v.id === this._selectedVariantId);
1719
+ if (found)
1720
+ return found;
1721
+ }
1722
+ if (result?.defaultVariantId) {
1723
+ const found = variants.find(v => v.id === result.defaultVariantId);
1724
+ if (found)
1725
+ return found;
1726
+ }
1727
+ return variants[0];
1728
+ }
1729
+ /**
1730
+ * 3 시안 picker — 썸네일 + 라벨 + 클릭 시 선택. 시안이 1개 이하면 안 그림.
1731
+ * 사용자가 다른 시안 선택 시 _selectedVariantId 갱신 → re-render → 큰 preview / stats / 추천 갱신.
1732
+ */
1733
+ _renderVariantPicker(variants, selected) {
1734
+ return html `
1735
+ <div class="variant-picker">
1736
+ <div class="variant-picker-header">
1737
+ <strong>${i18next.t('label.choose variant') || 'Choose AI Variant'}</strong>
1738
+ <span class="variant-picker-hint">
1739
+ ${i18next.t('hint.choose variant') ||
1740
+ 'Different AI interpretations of the same drawing. Choose the most accurate one.'}
1741
+ </span>
1742
+ <button
1743
+ class="regenerate-btn"
1744
+ @click=${this._onRegenerateVariants}
1745
+ ?disabled=${this._materializing}
1746
+ title=${i18next.t('hint.regenerate variants') ||
1747
+ 'Regenerate if none of the variants look right'}
1748
+ >
1749
+ ↻ ${i18next.t('button.regenerate') || 'Regenerate'}
1750
+ </button>
1751
+ </div>
1752
+ <div class="variant-grid">
1753
+ ${variants.map(v => html `
1754
+ <div
1755
+ class="variant-card ${selected?.id === v.id ? 'selected' : ''}"
1756
+ @click=${() => this._onSelectVariant(v.id)}
1757
+ >
1758
+ <div class="variant-thumb">
1759
+ <ox-board-preview .boardModel=${v.boardModel}></ox-board-preview>
1760
+ </div>
1761
+ <div class="variant-meta">
1762
+ <div class="variant-label">${v.label || v.id}</div>
1763
+ ${v.description
1764
+ ? html `<div class="variant-desc">${v.description}</div>`
1765
+ : ''}
1766
+ <div class="variant-stats">
1767
+ ${v.stats?.total ?? 0}개 객체
1768
+ ${v.stats?.matched ? html ` · ${v.stats.matched} 매핑` : ''}
1769
+ </div>
1770
+ </div>
1771
+ </div>
1772
+ `)}
1773
+ </div>
1774
+ </div>
1775
+ `;
1776
+ }
1777
+ _onSelectVariant(variantId) {
1778
+ if (this._selectedVariantId === variantId)
1779
+ return;
1780
+ this._selectedVariantId = variantId;
1781
+ // 선택된 variant 의 subjectName / subjectDescription 으로 자동 채움 — 사용자가 이미
1782
+ // 수정한 값은 보존하지 않음 (시안 바뀌면 새 정체성이 더 적절).
1783
+ const variant = (this._session?.result?.variants ?? []).find((v) => v.id === variantId);
1784
+ if (variant?.subjectName) {
1785
+ // shortenName 같은 정규화는 서버 suggestBoardMeta 가 이미 처리. 여기는 그대로.
1786
+ this._newBoardName = variant.subjectName.slice(0, 30).trim() || this._newBoardName;
1787
+ }
1788
+ if (variant?.subjectDescription) {
1789
+ this._newBoardDescription = variant.subjectDescription;
1790
+ }
1791
+ }
1792
+ async _onRegenerateVariants() {
1793
+ if (!this._attachment)
1794
+ return;
1795
+ // 기존 review 결과 폐기, progress 단계로 되돌리고 import 다시 시작.
1796
+ this._step = 'progress';
1797
+ this._session = undefined;
1798
+ this._selectedVariantId = '';
1799
+ this._materializeError = undefined;
1800
+ try {
1801
+ const session = await importBoardAsync({
1802
+ attachmentId: this._attachment.id,
1803
+ userPrompt: this._userPrompt.trim() || undefined
1804
+ });
1805
+ this._session = session;
1806
+ this._startPolling();
1807
+ }
1808
+ catch (err) {
1809
+ const message = err?.message || String(err);
1810
+ this._importError = message;
1811
+ notify({ level: 'error', message });
1812
+ }
1813
+ }
1814
+ /**
1815
+ * AI 분석 요약 카드 — VLM 이 도면을 어떻게 이해했고 어떤 관점으로 import 를 진행했는지
1816
+ * 사용자 review 에 노출. metadata 가 비어있으면 (DXF 임포트 등 VLM 미사용 경로) 카드 자체 생략.
1817
+ *
1818
+ * selected variant 의 importStrategy / subjectName / subjectDescription 은 시안별 전용.
1819
+ */
1820
+ _renderAISummary(result, selectedVariant) {
1821
+ const meta = result?.metadata;
1822
+ if (!meta)
1823
+ return '';
1824
+ const viewType = meta.viewType;
1825
+ const confidence = typeof meta.viewTypeConfidence === 'number' ? meta.viewTypeConfidence : undefined;
1826
+ const reasoning = meta.viewTypeReasoning;
1827
+ // importStrategy 는 selected variant 우선, 없으면 metadata 의 (default) 사용
1828
+ const importStrategy = (selectedVariant?.importStrategy ?? meta.importStrategy);
1829
+ const userPrompt = meta.userPrompt;
1830
+ const dist = result?.categoryDistribution;
1831
+ const aiClient = meta.aiClientId;
1832
+ const imageDims = meta.imageWidth && meta.imageHeight ? `${meta.imageWidth}×${meta.imageHeight}px` : undefined;
1833
+ const viewTypeLabel = (() => {
1834
+ switch (viewType) {
1835
+ case 'top-down':
1836
+ return i18next.t('label.viewtype top-down') || 'Floor Plan (top-down)';
1837
+ case 'perspective-3d':
1838
+ return i18next.t('label.viewtype perspective-3d') || '3D Render / Bird\'s Eye';
1839
+ case 'photo':
1840
+ return i18next.t('label.viewtype photo') || 'Photo';
1841
+ default:
1842
+ return viewType || '-';
1843
+ }
1844
+ })();
1845
+ return html `
1846
+ <div class="ai-summary">
1847
+ <div class="title">🔍 ${i18next.t('label.ai analysis') || 'AI Analysis Summary'}</div>
1848
+
1849
+ <div class="row">
1850
+ <span class="label">${i18next.t('label.view type') || 'Drawing Type'}</span>
1851
+ <span class="value">
1852
+ <span class="badge">${viewTypeLabel}</span>
1853
+ ${typeof confidence === 'number'
1854
+ ? html `<span class="badge">${i18next.t('label.confidence') || 'Confidence'} ${Math.round(confidence * 100)}%</span>`
1855
+ : ''}
1856
+ </span>
1857
+ </div>
1858
+
1859
+ ${reasoning
1860
+ ? html `
1861
+ <div class="row">
1862
+ <span class="label">${i18next.t('label.reasoning') || 'Reasoning'}</span>
1863
+ <span class="value">${reasoning}</span>
1864
+ </div>
1865
+ `
1866
+ : ''}
1867
+ ${importStrategy
1868
+ ? html `
1869
+ <div class="strategy">
1870
+ <div class="strategy-label">
1871
+ 💡 ${i18next.t('label.ai intent') || 'AI Intent / Approach'}
1872
+ </div>
1873
+ <div class="strategy-text">${importStrategy}</div>
1874
+ </div>
1875
+ `
1876
+ : ''}
1877
+ ${userPrompt
1878
+ ? html `
1879
+ <div class="row">
1880
+ <span class="label">${i18next.t('label.user hint') || 'User Hint'}</span>
1881
+ <span class="value">"${userPrompt}"</span>
1882
+ </div>
1883
+ `
1884
+ : ''}
1885
+ ${dist && Object.keys(dist).length > 0
1886
+ ? html `
1887
+ <div class="row">
1888
+ <span class="label">${i18next.t('label.categories') || 'Categories'}</span>
1889
+ <span class="value">
1890
+ ${Object.entries(dist).map(([cat, n]) => html `<span class="badge">${cat} ${n}</span>`)}
1891
+ </span>
1892
+ </div>
1893
+ `
1894
+ : ''}
1895
+ ${imageDims
1896
+ ? html `
1897
+ <div class="row">
1898
+ <span class="label">${i18next.t('label.image size') || 'Image Size'}</span>
1899
+ <span class="value">${imageDims}</span>
1900
+ </div>
1901
+ `
1902
+ : ''}
1903
+ ${aiClient
1904
+ ? html `
1905
+ <div class="row">
1906
+ <span class="label">${i18next.t('label.ai client') || 'AI Model'}</span>
1907
+ <span class="value">${aiClient}</span>
1908
+ </div>
1909
+ `
1910
+ : ''}
1911
+ ${viewType === 'perspective-3d' || viewType === 'photo'
1912
+ ? html `
1913
+ <div class="row">
1914
+ <span class="label" style="color:#a0892a;">⚠️</span>
1915
+ <span class="value" style="color:#7a5e00;font-style:italic;">
1916
+ ${i18next.t('text.perspective notice') ||
1917
+ 'Perspective drawings are difficult to infer floor plan positions — positional accuracy may be low. Please verify and adjust.'}
1918
+ </span>
1919
+ </div>
1920
+ `
1921
+ : ''}
1922
+ </div>
1923
+ `;
1924
+ }
1925
+ /** 사용자 입력 또는 첨부파일 이름 (확장자 제거) 을 fallback. 빈 input 일 때만 fallback. */
1926
+ _effectiveName() {
1927
+ if (this._newBoardName.trim())
1928
+ return this._newBoardName.trim();
1929
+ if (this._attachment?.name)
1930
+ return this._attachment.name.replace(/\.[^.]+$/, '');
1931
+ return '';
1932
+ }
1933
+ async _materialize() {
1934
+ const name = this._effectiveName();
1935
+ if (!name)
1936
+ return;
1937
+ if (this._isFileMode() && !this._session?.id)
1938
+ return;
1939
+ this._materializing = true;
1940
+ this._materializeError = undefined;
1941
+ try {
1942
+ const description = this._newBoardDescription.trim();
1943
+ const groupId = this._selectedGroupId || undefined;
1944
+ const type = this._selectedBoardType;
1945
+ let boardId;
1946
+ if (this._isFileMode()) {
1947
+ // import 결과 → materializeImportSession (variant 선택)
1948
+ const variantId = this._selectedVariantId || undefined;
1949
+ const board = await materializeImportSession({
1950
+ sessionId: this._session.id,
1951
+ name,
1952
+ ...(description ? { description } : {}),
1953
+ ...(groupId ? { groupId } : {}),
1954
+ type,
1955
+ ...(variantId ? { variantId } : {})
1956
+ });
1957
+ boardId = board?.id;
1958
+ }
1959
+ else {
1960
+ // blank / template — 직접 createBoard.
1961
+ const model = this._uploadMode === 'template' && this._selectedTemplate?.model
1962
+ ? this._selectedTemplate.model
1963
+ : { width: 800, height: 600 };
1964
+ const thumbnail = this._uploadMode === 'template' ? this._selectedTemplate?.thumbnail : undefined;
1965
+ const created = await createBoard({
1966
+ name,
1967
+ description,
1968
+ type,
1969
+ groupId: groupId || '',
1970
+ model,
1971
+ ...(thumbnail ? { thumbnail } : {})
1972
+ });
1973
+ boardId = created?.createBoard?.id ?? created?.id;
1974
+ }
1975
+ if (!boardId)
1976
+ throw new Error('Board id missing in response.');
1977
+ this._newBoardId = boardId;
1978
+ if (this._isFileMode()) {
1979
+ this._step = 'done';
1980
+ }
1981
+ else {
1982
+ // blank / template — 생성 즉시 모델러로 이동
1983
+ navigate(`board-modeller/${boardId}`);
1984
+ }
1985
+ }
1986
+ catch (err) {
1987
+ const message = err?.message || String(err);
1988
+ this._materializeError = message;
1989
+ notify({ level: 'error', message });
1990
+ }
1991
+ finally {
1992
+ this._materializing = false;
1993
+ }
1994
+ }
1995
+ // ── done step ───────────────────────────────────────────────────
1996
+ _renderDoneStep() {
1997
+ return html `
1998
+ <div class="done-card">
1999
+ <md-icon class="icon">check_circle</md-icon>
2000
+ <div class="title">${i18next.t('text.import success') || 'Board created successfully'}</div>
2001
+ <div class="meta">
2002
+ ${this._effectiveName()} · ${i18next.t('label.draft') || 'Draft'}
2003
+ </div>
2004
+ </div>
2005
+ <div class="controls">
2006
+ <button class="secondary" @click=${this._retryFromUpload}>
2007
+ ${i18next.t('button.import another') || 'Import Another Drawing'}
2008
+ </button>
2009
+ <button class="primary" @click=${this._goToModeller} ?disabled=${!this._newBoardId}>
2010
+ ${i18next.t('button.open modeller') || 'Open Modeller'}
2011
+ </button>
2012
+ </div>
2013
+ `;
2014
+ }
2015
+ _goToModeller() {
2016
+ if (!this._newBoardId)
2017
+ return;
2018
+ navigate(`board-modeller/${this._newBoardId}`);
2019
+ }
2020
+ _formatSize(bytes) {
2021
+ if (bytes < 1024)
2022
+ return `${bytes} B`;
2023
+ if (bytes < 1024 * 1024)
2024
+ return `${(bytes / 1024).toFixed(1)} KB`;
2025
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
2026
+ }
2027
+ // 페이지 라이프사이클
2028
+ pageUpdated(_changes, _lifecycle) {
2029
+ if (this.active) {
2030
+ // wizard 가 'done' 상태로 끝난 뒤 다시 진입하면 초기 상태로 리셋.
2031
+ if (this._step === 'done') {
2032
+ this._resetAll();
2033
+ }
2034
+ // 처음부터 탭이 디폴트 — 첫 진입 시 그룹 목록 로딩.
2035
+ if (!this._groupsLoaded) {
2036
+ this._loadGroups();
2037
+ }
2038
+ }
2039
+ }
2040
+ /** wizard 모든 state 를 초기값으로 — 'done' 후 재진입 / 처음부터 시작 시 사용. */
2041
+ _resetAll() {
2042
+ this._step = 'upload';
2043
+ this._attachment = undefined;
2044
+ this._session = undefined;
2045
+ this._uploading = false;
2046
+ this._uploadError = undefined;
2047
+ this._uploadMode = 'blank';
2048
+ this._browseItems = [];
2049
+ this._browseLoading = false;
2050
+ this._browseSearch = '';
2051
+ this._userPrompt = '';
2052
+ this._importError = undefined;
2053
+ this._newBoardName = '';
2054
+ this._newBoardDescription = '';
2055
+ this._selectedVariantId = '';
2056
+ this._selectedTemplate = undefined;
2057
+ this._templates = [];
2058
+ this._templatesLoading = false;
2059
+ this._templatesLoaded = false;
2060
+ this._templateSearch = '';
2061
+ this._selectedGroupId = '';
2062
+ this._selectedBoardType = 'main';
2063
+ this._materializing = false;
2064
+ this._newBoardId = undefined;
2065
+ this._materializeError = undefined;
2066
+ }
2067
+ };
2068
+ __decorate([
2069
+ state(),
2070
+ __metadata("design:type", String)
2071
+ ], BoardCreateWizardPage.prototype, "_step", void 0);
2072
+ __decorate([
2073
+ state(),
2074
+ __metadata("design:type", Object)
2075
+ ], BoardCreateWizardPage.prototype, "_attachment", void 0);
2076
+ __decorate([
2077
+ state(),
2078
+ __metadata("design:type", Object)
2079
+ ], BoardCreateWizardPage.prototype, "_session", void 0);
2080
+ __decorate([
2081
+ state(),
2082
+ __metadata("design:type", Object)
2083
+ ], BoardCreateWizardPage.prototype, "_uploading", void 0);
2084
+ __decorate([
2085
+ state(),
2086
+ __metadata("design:type", String)
2087
+ ], BoardCreateWizardPage.prototype, "_uploadError", void 0);
2088
+ __decorate([
2089
+ state(),
2090
+ __metadata("design:type", String)
2091
+ ], BoardCreateWizardPage.prototype, "_uploadMode", void 0);
2092
+ __decorate([
2093
+ state(),
2094
+ __metadata("design:type", Array)
2095
+ ], BoardCreateWizardPage.prototype, "_browseItems", void 0);
2096
+ __decorate([
2097
+ state(),
2098
+ __metadata("design:type", Object)
2099
+ ], BoardCreateWizardPage.prototype, "_browseLoading", void 0);
2100
+ __decorate([
2101
+ state(),
2102
+ __metadata("design:type", Object)
2103
+ ], BoardCreateWizardPage.prototype, "_browseSearch", void 0);
2104
+ __decorate([
2105
+ state(),
2106
+ __metadata("design:type", Object)
2107
+ ], BoardCreateWizardPage.prototype, "_userPrompt", void 0);
2108
+ __decorate([
2109
+ state(),
2110
+ __metadata("design:type", Array)
2111
+ ], BoardCreateWizardPage.prototype, "_templates", void 0);
2112
+ __decorate([
2113
+ state(),
2114
+ __metadata("design:type", Object)
2115
+ ], BoardCreateWizardPage.prototype, "_templatesLoading", void 0);
2116
+ __decorate([
2117
+ state(),
2118
+ __metadata("design:type", Object)
2119
+ ], BoardCreateWizardPage.prototype, "_templatesLoaded", void 0);
2120
+ __decorate([
2121
+ state(),
2122
+ __metadata("design:type", Object)
2123
+ ], BoardCreateWizardPage.prototype, "_templateSearch", void 0);
2124
+ __decorate([
2125
+ state(),
2126
+ __metadata("design:type", Object)
2127
+ ], BoardCreateWizardPage.prototype, "_selectedTemplate", void 0);
2128
+ __decorate([
2129
+ state(),
2130
+ __metadata("design:type", Array)
2131
+ ], BoardCreateWizardPage.prototype, "_groups", void 0);
2132
+ __decorate([
2133
+ state(),
2134
+ __metadata("design:type", Object)
2135
+ ], BoardCreateWizardPage.prototype, "_groupsLoaded", void 0);
2136
+ __decorate([
2137
+ state(),
2138
+ __metadata("design:type", Object)
2139
+ ], BoardCreateWizardPage.prototype, "_selectedGroupId", void 0);
2140
+ __decorate([
2141
+ state(),
2142
+ __metadata("design:type", String)
2143
+ ], BoardCreateWizardPage.prototype, "_selectedBoardType", void 0);
2144
+ __decorate([
2145
+ state(),
2146
+ __metadata("design:type", String)
2147
+ ], BoardCreateWizardPage.prototype, "_importError", void 0);
2148
+ __decorate([
2149
+ state(),
2150
+ __metadata("design:type", Object)
2151
+ ], BoardCreateWizardPage.prototype, "_newBoardName", void 0);
2152
+ __decorate([
2153
+ state(),
2154
+ __metadata("design:type", Object)
2155
+ ], BoardCreateWizardPage.prototype, "_newBoardDescription", void 0);
2156
+ __decorate([
2157
+ state(),
2158
+ __metadata("design:type", String)
2159
+ ], BoardCreateWizardPage.prototype, "_selectedVariantId", void 0);
2160
+ __decorate([
2161
+ state(),
2162
+ __metadata("design:type", Object)
2163
+ ], BoardCreateWizardPage.prototype, "_materializing", void 0);
2164
+ __decorate([
2165
+ state(),
2166
+ __metadata("design:type", String)
2167
+ ], BoardCreateWizardPage.prototype, "_newBoardId", void 0);
2168
+ __decorate([
2169
+ state(),
2170
+ __metadata("design:type", String)
2171
+ ], BoardCreateWizardPage.prototype, "_materializeError", void 0);
2172
+ BoardCreateWizardPage = __decorate([
2173
+ customElement('board-create-wizard-page')
2174
+ ], BoardCreateWizardPage);
2175
+ export { BoardCreateWizardPage };
2176
+ //# sourceMappingURL=board-create-wizard-page.js.map