memi-board 0.40.0 → 0.41.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.
@@ -73,6 +73,26 @@ const aiRunning = ref(false)
73
73
  const aiError = ref('')
74
74
  const aiCustomDialogOpen = ref(false)
75
75
  const aiCustomInstruction = ref('')
76
+
77
+ interface AiPreviewPending {
78
+ action: WritingAssistantAction
79
+ result: string
80
+ hasSelection: boolean
81
+ from: number
82
+ to: number
83
+ /** true면 before/after를 일반 텍스트로, false면 HTML로 렌더링 */
84
+ isPlainText: boolean
85
+ }
86
+ const aiPreviewOpen = ref(false)
87
+ const aiPreviewBefore = ref('')
88
+ const aiPreviewPending = ref<AiPreviewPending | null>(null)
89
+ const aiPreviewLabels = computed(() => {
90
+ switch (aiPreviewPending.value?.action) {
91
+ case 'title': return { before: '현재 제목', after: '새 제목' }
92
+ case 'continue': return { before: '현재 내용', after: '추가될 내용' }
93
+ default: return { before: '이전', after: '이후' }
94
+ }
95
+ })
76
96
  /** 관리자 전용 — 비속어 필터 건너뛰기 (테스트/공지 등 의도적 작성용) */
77
97
  const adminSkipModeration = ref(false)
78
98
 
@@ -594,30 +614,21 @@ async function runWritingAssistant(action: WritingAssistantAction, customInstruc
594
614
  ? plainTextFromHtml(content.value)
595
615
  : hasSelection ? selectedText : content.value
596
616
 
617
+ const isPlainText = hasSelection || action === 'title'
618
+
597
619
  aiRunning.value = true
598
620
  try {
599
621
  const result = await assist({
600
622
  action,
601
623
  content: source,
602
624
  title: title.value,
603
- selection: hasSelection || action === 'title',
625
+ selection: isPlainText,
604
626
  customInstruction,
605
627
  })
606
628
 
607
- if (action === 'title') {
608
- title.value = result.replace(/^['"“”]|['"“”]$/g, '').trim()
609
- }
610
- else if (action === 'continue') {
611
- const position = hasSelection ? to : editor.state.doc.content.size
612
- editor.chain().focus().insertContentAt(position, result).run()
613
- }
614
- else if (hasSelection) {
615
- editor.chain().focus().insertContentAt({ from, to }, result).run()
616
- }
617
- else {
618
- editor.commands.setContent(result, { emitUpdate: true })
619
- editor.chain().focus('end').run()
620
- }
629
+ aiPreviewBefore.value = action === 'title' ? title.value : source
630
+ aiPreviewPending.value = { action, result, hasSelection, from, to, isPlainText }
631
+ aiPreviewOpen.value = true
621
632
  }
622
633
  catch (cause) {
623
634
  console.warn('[memi-board] writing assistant failed', cause)
@@ -628,6 +639,35 @@ async function runWritingAssistant(action: WritingAssistantAction, customInstruc
628
639
  }
629
640
  }
630
641
 
642
+ function closeAiPreview() {
643
+ aiPreviewOpen.value = false
644
+ aiPreviewPending.value = null
645
+ aiPreviewBefore.value = ''
646
+ }
647
+
648
+ function applyAiPreview() {
649
+ const pending = aiPreviewPending.value
650
+ const editor = resolveEditor()
651
+ if (!pending || !editor) return
652
+ const { action, result, hasSelection, from, to } = pending
653
+
654
+ if (action === 'title') {
655
+ title.value = result.replace(/^['"“”]|['"“”]$/g, '').trim()
656
+ }
657
+ else if (action === 'continue') {
658
+ const position = hasSelection ? to : editor.state.doc.content.size
659
+ editor.chain().focus().insertContentAt(position, result).run()
660
+ }
661
+ else if (hasSelection) {
662
+ editor.chain().focus().insertContentAt({ from, to }, result).run()
663
+ }
664
+ else {
665
+ editor.commands.setContent(result, { emitUpdate: true })
666
+ editor.chain().focus('end').run()
667
+ }
668
+ closeAiPreview()
669
+ }
670
+
631
671
  // 제목에서 Tab 누르면 툴바 버튼들 건너뛰고 본문으로 바로 이동
632
672
  function focusContentFromTitle() {
633
673
  resolveEditor()?.chain().focus().run()
@@ -1333,6 +1373,63 @@ async function handleSubmit() {
1333
1373
  </div>
1334
1374
  </template>
1335
1375
  </UModal>
1376
+
1377
+ <UModal
1378
+ v-model:open="aiPreviewOpen"
1379
+ title="AI 결과 확인"
1380
+ :ui="{ content: 'sm:max-w-4xl lg:max-w-6xl' }"
1381
+ >
1382
+ <template #body>
1383
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
1384
+ <div class="flex flex-col gap-1">
1385
+ <span class="text-xs font-medium text-muted">{{ aiPreviewLabels.before }}</span>
1386
+ <div class="overflow-y-auto rounded-lg border border-default bg-elevated/30 p-3 text-sm sm:max-h-96">
1387
+ <p
1388
+ v-if="aiPreviewPending?.isPlainText"
1389
+ class="whitespace-pre-wrap"
1390
+ >{{ aiPreviewBefore }}</p>
1391
+ <div
1392
+ v-else
1393
+ v-html="aiPreviewBefore"
1394
+ />
1395
+ </div>
1396
+ </div>
1397
+ <div class="flex flex-col gap-1">
1398
+ <span class="text-xs font-medium text-primary">{{ aiPreviewLabels.after }}</span>
1399
+ <div class="overflow-y-auto rounded-lg border border-primary/40 bg-primary/5 p-3 text-sm sm:max-h-96">
1400
+ <p
1401
+ v-if="aiPreviewPending?.isPlainText"
1402
+ class="whitespace-pre-wrap"
1403
+ >{{ aiPreviewPending?.result }}</p>
1404
+ <div
1405
+ v-else
1406
+ v-html="aiPreviewPending?.result"
1407
+ />
1408
+ </div>
1409
+ </div>
1410
+ </div>
1411
+ </template>
1412
+
1413
+ <template #footer>
1414
+ <div class="flex w-full justify-end gap-2">
1415
+ <UButton
1416
+ type="button"
1417
+ color="neutral"
1418
+ variant="ghost"
1419
+ @click="closeAiPreview"
1420
+ >
1421
+ 취소
1422
+ </UButton>
1423
+ <UButton
1424
+ type="button"
1425
+ icon="i-lucide-check"
1426
+ @click="applyAiPreview"
1427
+ >
1428
+ 적용
1429
+ </UButton>
1430
+ </div>
1431
+ </template>
1432
+ </UModal>
1336
1433
  </template>
1337
1434
 
1338
1435
  <style scoped>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memi-board",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "type": "module",
5
5
  "description": "Nuxt 4 + Nuxt UI + VueFire/Firebase 전용 게시판 모듈",
6
6
  "license": "MIT",