memi-board 0.44.0 → 0.45.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.
Files changed (84) hide show
  1. package/README.md +54 -3
  2. package/dist/ar-BCQdRw_S.js +622 -0
  3. package/dist/ar-BCQdRw_S.js.map +1 -0
  4. package/dist/composables/fetchPublicSeo.d.ts +4 -1
  5. package/dist/composables/useMemiBoardAutoTranslate.d.ts +33 -0
  6. package/dist/composables/useMemiBoardModeration.d.ts +5 -1
  7. package/dist/composables/useMemiBoardReports.d.ts +6 -1
  8. package/dist/composables/useMemiBoardSections.d.ts +2 -1
  9. package/dist/composables/useMemiBoardUsers.d.ts +4 -0
  10. package/dist/composables/useMemiBoardWritingAssistant.d.ts +9 -1
  11. package/dist/config.d.ts +5 -0
  12. package/dist/de-ddWQNaqs.js +622 -0
  13. package/dist/de-ddWQNaqs.js.map +1 -0
  14. package/dist/en-BVUBLkxc.js +648 -0
  15. package/dist/en-BVUBLkxc.js.map +1 -0
  16. package/dist/es-7JHjGVlM.js +622 -0
  17. package/dist/es-7JHjGVlM.js.map +1 -0
  18. package/dist/fr-C3NCMNoy.js +622 -0
  19. package/dist/fr-C3NCMNoy.js.map +1 -0
  20. package/dist/id-C8hCAMPJ.js +622 -0
  21. package/dist/id-C8hCAMPJ.js.map +1 -0
  22. package/dist/index.d.ts +10 -1
  23. package/dist/index.js +1218 -1487
  24. package/dist/index.js.map +1 -1
  25. package/dist/ja-oTWri4hY.js +622 -0
  26. package/dist/ja-oTWri4hY.js.map +1 -0
  27. package/dist/module.mjs +55 -31
  28. package/dist/module.mjs.map +1 -1
  29. package/dist/pt-B9EH4scA.js +622 -0
  30. package/dist/pt-B9EH4scA.js.map +1 -0
  31. package/dist/runtime/components/Attachments.vue +4 -2
  32. package/dist/runtime/components/AuthorMenu.vue +14 -12
  33. package/dist/runtime/components/AvatarCropModal.vue +8 -5
  34. package/dist/runtime/components/CategorySettings.vue +65 -43
  35. package/dist/runtime/components/CommentFeed.vue +9 -7
  36. package/dist/runtime/components/CommentForm.vue +16 -10
  37. package/dist/runtime/components/CommentItem.vue +25 -26
  38. package/dist/runtime/components/CommentList.vue +7 -4
  39. package/dist/runtime/components/CommentThread.vue +7 -5
  40. package/dist/runtime/components/Detail.vue +20 -15
  41. package/dist/runtime/components/Editor.vue +145 -135
  42. package/dist/runtime/components/Footer.vue +14 -13
  43. package/dist/runtime/components/List.vue +31 -29
  44. package/dist/runtime/components/ListDefault.vue +4 -3
  45. package/dist/runtime/components/ListDense.vue +6 -5
  46. package/dist/runtime/components/ListImage.vue +4 -2
  47. package/dist/runtime/components/ListVideo.vue +3 -2
  48. package/dist/runtime/components/ListViewSwitch.vue +10 -6
  49. package/dist/runtime/components/MiniCard.vue +3 -2
  50. package/dist/runtime/components/MiniComments.vue +5 -4
  51. package/dist/runtime/components/MiniList.vue +3 -2
  52. package/dist/runtime/components/MiniPicker.vue +10 -9
  53. package/dist/runtime/components/MiniPost.vue +3 -2
  54. package/dist/runtime/components/MiniPosts.vue +5 -4
  55. package/dist/runtime/components/Profile.vue +12 -7
  56. package/dist/runtime/components/ProfileEditModal.vue +14 -13
  57. package/dist/runtime/components/ReportButton.vue +20 -10
  58. package/dist/runtime/components/ReportList.vue +11 -10
  59. package/dist/runtime/components/Section.vue +3 -2
  60. package/dist/runtime/components/Settings.vue +84 -62
  61. package/dist/runtime/components/ShareButton.vue +3 -1
  62. package/dist/runtime/components/SignIn.vue +14 -8
  63. package/dist/runtime/components/SwipeHint.vue +5 -3
  64. package/dist/runtime/components/UserCommentList.vue +8 -6
  65. package/dist/runtime/components/UserPostList.vue +6 -4
  66. package/dist/runtime/components/Users.vue +16 -10
  67. package/dist/runtime/composables/useMemiBoardSeo.ts +16 -10
  68. package/dist/slugify-6bG6GSO_.js +942 -0
  69. package/dist/slugify-6bG6GSO_.js.map +1 -0
  70. package/dist/storage.js +72 -71
  71. package/dist/storage.js.map +1 -1
  72. package/dist/types.d.ts +4 -0
  73. package/dist/utils/boardSeo.d.ts +5 -5
  74. package/dist/utils/compressImage.d.ts +2 -1
  75. package/dist/utils/formatDate.d.ts +6 -4
  76. package/dist/utils/imeSafeSubmit.d.ts +2 -0
  77. package/dist/utils/moderation-prompt.d.ts +18 -1
  78. package/dist/utils/moderation-strike.d.ts +2 -1
  79. package/dist/utils/section.d.ts +15 -2
  80. package/dist/zh-D5rDYV6E.js +622 -0
  81. package/dist/zh-D5rDYV6E.js.map +1 -0
  82. package/package.json +2 -3
  83. package/dist/slugify-Cm_pi2al.js +0 -124
  84. package/dist/slugify-Cm_pi2al.js.map +0 -1
@@ -7,6 +7,7 @@
7
7
  * 커스텀은 handlers.image(파일 업로드), YouTube 임베드 + DOM paste/drop.
8
8
  */
9
9
  import { ref, computed, watch } from 'vue'
10
+ import { imeSafeSubmitClick, imeSafeSubmitPointerDown } from 'memi-board/runtime'
10
11
  import Youtube from '@tiptap/extension-youtube'
11
12
  import type { DropdownMenuItem, EditorSuggestionMenuItem } from '@nuxt/ui'
12
13
  import { mapEditorItems } from '@nuxt/ui/utils/editor'
@@ -16,6 +17,7 @@ import {
16
17
  useMemiBoardAuth,
17
18
  useMemiBoardPosts,
18
19
  useMemiBoardModeration,
20
+ useMemiBoardI18n,
19
21
  useMemiBoardSettings,
20
22
  hasBodyImage,
21
23
  hasBodyText,
@@ -38,10 +40,13 @@ const emit = defineEmits<{ saved: [id: string], cancel: [] }>()
38
40
  const { user, isSignedIn, isAdmin, isWriteRestricted, restrictedMessage } = useMemiBoardAuth()
39
41
  const resolvedBoardId = computed(() => props.boardId || props.fixedCategory || '')
40
42
  const { createPostId, getPost, createPost, updatePost, resolveUniqueSlug } = useMemiBoardPosts(resolvedBoardId)
41
- const { checkText } = useMemiBoardModeration()
42
43
  const { getBoard, ensureSettings } = useMemiBoardSettings()
44
+ // 보드 설정 언어 → 호스트 언어 순.
45
+ const { t, dir, locale } = useMemiBoardI18n({ boardLocale: () => getBoard(resolvedBoardId.value)?.locale })
46
+ // provide 한 컴포넌트 자신은 inject 를 못 받으므로 AI 프롬프트 언어를 직접 넘긴다.
47
+ const { checkText } = useMemiBoardModeration({ locale })
43
48
  const { uploadEditorImage } = useMemiBoardStorage()
44
- const { assist } = useMemiBoardWritingAssistant()
49
+ const { assist } = useMemiBoardWritingAssistant({ locale })
45
50
 
46
51
  const title = ref('')
47
52
  /** UEditor content-type=html (shineb 와 동일) */
@@ -90,9 +95,9 @@ const aiPreviewBefore = ref('')
90
95
  const aiPreviewPending = ref<AiPreviewPending | null>(null)
91
96
  const aiPreviewLabels = computed(() => {
92
97
  switch (aiPreviewPending.value?.action) {
93
- case 'title': return { before: '현재 제목', after: '새 제목' }
94
- case 'continue': return { before: '현재 내용', after: '추가될 내용' }
95
- default: return { before: '이전', after: '이후' }
98
+ case 'title': return { before: t('editor.aiPreview.currentTitle'), after: t('editor.aiPreview.newTitle') }
99
+ case 'continue': return { before: t('editor.aiPreview.currentContent'), after: t('editor.aiPreview.appendedContent') }
100
+ default: return { before: t('editor.aiPreview.before'), after: t('editor.aiPreview.after') }
96
101
  }
97
102
  })
98
103
  /** 관리자 전용 — 비속어 필터 건너뛰기 (테스트/공지 등 의도적 작성용) */
@@ -186,25 +191,25 @@ function appendCoverUrls(urls: string[]) {
186
191
  }
187
192
  coverSlots.value = next
188
193
  if (urls.length && coverSlots.value.length >= COVER_IMAGE_MAX) {
189
- imageUploadError.value = `사진은 최대 ${COVER_IMAGE_MAX}장까지 올릴 수 있어요.`
194
+ imageUploadError.value = t('editor.errors.coverMax', { max: COVER_IMAGE_MAX })
190
195
  }
191
196
  }
192
197
 
193
198
  function normalizeExternalImageUrl(value: string): string {
194
199
  const raw = value.trim()
195
- if (!raw) throw new Error('이미지 주소를 입력해 주세요.')
200
+ if (!raw) throw new Error(t('editor.errors.imageUrlRequired'))
196
201
  let url: URL
197
202
  try {
198
203
  url = new URL(raw)
199
204
  }
200
205
  catch {
201
- throw new Error('올바른 이미지 주소를 입력해 주세요.')
206
+ throw new Error(t('editor.errors.imageUrlInvalid'))
202
207
  }
203
208
  if (url.protocol !== 'https:') {
204
- throw new Error('HTTPS 이미지 주소만 사용할 수 있습니다.')
209
+ throw new Error(t('editor.errors.imageUrlHttps'))
205
210
  }
206
211
  if (url.username || url.password) {
207
- throw new Error('로그인 정보가 포함된 주소는 사용할 수 없습니다.')
212
+ throw new Error(t('editor.errors.imageUrlCredentials'))
208
213
  }
209
214
  return url.toString()
210
215
  }
@@ -214,19 +219,19 @@ function verifyExternalImage(url: string): Promise<string> {
214
219
  const image = new Image()
215
220
  const timeout = window.setTimeout(() => {
216
221
  image.src = ''
217
- reject(new Error('이미지를 불러오는 데 시간이 너무 오래 걸립니다.'))
222
+ reject(new Error(t('editor.errors.imageTimeout')))
218
223
  }, 10_000)
219
224
  image.onload = () => {
220
225
  window.clearTimeout(timeout)
221
226
  if (!image.naturalWidth || !image.naturalHeight) {
222
- reject(new Error('표시할 수 없는 이미지입니다.'))
227
+ reject(new Error(t('editor.errors.imageNotDisplayable')))
223
228
  return
224
229
  }
225
230
  resolve(url)
226
231
  }
227
232
  image.onerror = () => {
228
233
  window.clearTimeout(timeout)
229
- reject(new Error('이미지를 불러올 수 없습니다. 주소나 외부 링크 허용 여부를 확인해 주세요.'))
234
+ reject(new Error(t('editor.errors.imageLoadFailed')))
230
235
  }
231
236
  image.referrerPolicy = 'no-referrer'
232
237
  image.src = url
@@ -258,13 +263,13 @@ async function addExternalImage() {
258
263
  }
259
264
  else {
260
265
  const editor = imageDialogEditor.value ?? resolveEditor()
261
- if (!editor) throw new Error('에디터를 준비하지 못했습니다. 다시 시도해 주세요.')
266
+ if (!editor) throw new Error(t('editor.errors.editorNotReady'))
262
267
  editor.chain().focus().setImage({ src: url }).run()
263
268
  }
264
269
  imageDialogOpen.value = false
265
270
  }
266
271
  catch (cause) {
267
- imageUploadError.value = cause instanceof Error ? cause.message : '이미지 링크를 추가하지 못했습니다.'
272
+ imageUploadError.value = cause instanceof Error ? cause.message : t('editor.errors.imageLinkFailed')
268
273
  }
269
274
  finally {
270
275
  externalImageChecking.value = false
@@ -355,10 +360,10 @@ function onCoverItemDragEnd() {
355
360
 
356
361
  async function doUploadImage(file: File): Promise<EditorImageEntry> {
357
362
  if (!file.type.startsWith('image/')) {
358
- throw new Error('이미지 파일만 업로드할 수 있습니다.')
363
+ throw new Error(t('editor.errors.imageOnly'))
359
364
  }
360
365
  if (file.size > EDITOR_IMAGE_SOURCE_MAX_BYTES) {
361
- throw new Error(`원본 이미지는 ${EDITOR_IMAGE_SOURCE_MAX_BYTES / 1024 / 1024}MB 이하여야 합니다.`)
366
+ throw new Error(t('editor.errors.imageTooLarge', { mb: EDITOR_IMAGE_SOURCE_MAX_BYTES / 1024 / 1024 }))
362
367
  }
363
368
  const entry = await uploadEditorImage(file, imageNamespace())
364
369
  uploadedEditorImages.value.push(entry)
@@ -378,7 +383,7 @@ async function uploadAndSetImage(editor: any, file: File) {
378
383
  editor.chain().focus().setImage({ src: entry.originalUrl }).run()
379
384
  }
380
385
  catch (e) {
381
- imageUploadError.value = (e as Error).message || '이미지 업로드에 실패했습니다.'
386
+ imageUploadError.value = (e as Error).message || t('editor.errors.imageUploadFailed')
382
387
  }
383
388
  finally {
384
389
  imageUploading.value = false
@@ -390,7 +395,7 @@ async function uploadCoverImages(files: File[]) {
390
395
  if (!images.length) return
391
396
  const room = COVER_IMAGE_MAX - coverSlots.value.length
392
397
  if (room <= 0) {
393
- imageUploadError.value = `사진은 최대 ${COVER_IMAGE_MAX}장까지 올릴 수 있어요.`
398
+ imageUploadError.value = t('editor.errors.coverMax', { max: COVER_IMAGE_MAX })
394
399
  return
395
400
  }
396
401
  const batch = images.slice(0, room)
@@ -404,11 +409,11 @@ async function uploadCoverImages(files: File[]) {
404
409
  }
405
410
  appendCoverUrls(urls)
406
411
  if (images.length > batch.length) {
407
- imageUploadError.value = `사진은 최대 ${COVER_IMAGE_MAX}장까지 올릴 수 있어요.`
412
+ imageUploadError.value = t('editor.errors.coverMax', { max: COVER_IMAGE_MAX })
408
413
  }
409
414
  }
410
415
  catch (e) {
411
- imageUploadError.value = (e as Error).message || '이미지 업로드에 실패했습니다.'
416
+ imageUploadError.value = (e as Error).message || t('editor.errors.imageUploadFailed')
412
417
  }
413
418
  finally {
414
419
  imageUploading.value = false
@@ -487,7 +492,7 @@ function youtubeUrl(value: string | null | undefined): string | null {
487
492
  }
488
493
 
489
494
  function insertYoutube(editor: any, value?: string | null): boolean {
490
- const src = youtubeUrl(value ?? window.prompt('YouTube 링크를 입력하세요.'))
495
+ const src = youtubeUrl(value ?? window.prompt(t('editor.youtubePrompt')))
491
496
  if (!src) return false
492
497
  return editor.chain().focus().setYoutubeVideo({ src }).run()
493
498
  }
@@ -532,31 +537,31 @@ const selectedNode = ref<{ node: { type?: string }, pos: number }>()
532
537
 
533
538
  const suggestionItems = computed(() => {
534
539
  const insertItems: EditorSuggestionMenuItem<typeof handlers.value>[] = [
535
- { kind: 'horizontalRule', label: '구분선', icon: 'i-lucide-separator-horizontal' },
540
+ { kind: 'horizontalRule', label: t('editor.menu.horizontalRule'), icon: 'i-lucide-separator-horizontal' },
536
541
  ]
537
542
  if (!isImageEditor.value) {
538
543
  insertItems.unshift(
539
- { kind: 'image', label: '이미지', icon: 'i-lucide-image' },
544
+ { kind: 'image', label: t('editor.menu.image'), icon: 'i-lucide-image' },
540
545
  { kind: 'youtube', label: 'YouTube', icon: 'i-lucide-youtube' },
541
546
  )
542
547
  }
543
548
  return [
544
549
  [
545
- { type: 'label' as const, label: 'AI' },
546
- { kind: 'aiContinue' as const, label: 'AI로 이어쓰기', icon: 'i-lucide-sparkles' },
550
+ { type: 'label' as const, label: t('editor.menu.ai') },
551
+ { kind: 'aiContinue' as const, label: t('editor.menu.aiContinue'), icon: 'i-lucide-sparkles' },
547
552
  ],
548
553
  [
549
- { type: 'label' as const, label: '스타일' },
550
- { kind: 'paragraph' as const, label: '본문', icon: 'i-lucide-type' },
551
- { kind: 'heading' as const, level: 1 as const, label: '제목 1', icon: 'i-lucide-heading-1' },
552
- { kind: 'heading' as const, level: 2 as const, label: '제목 2', icon: 'i-lucide-heading-2' },
553
- { kind: 'heading' as const, level: 3 as const, label: '제목 3', icon: 'i-lucide-heading-3' },
554
- { kind: 'bulletList' as const, label: '글머리표 목록', icon: 'i-lucide-list' },
555
- { kind: 'orderedList' as const, label: '번호 목록', icon: 'i-lucide-list-ordered' },
556
- { kind: 'blockquote' as const, label: '인용문', icon: 'i-lucide-text-quote' },
557
- { kind: 'codeBlock' as const, label: '코드 블록', icon: 'i-lucide-square-code' },
554
+ { type: 'label' as const, label: t('editor.menu.style') },
555
+ { kind: 'paragraph' as const, label: t('editor.menu.paragraph'), icon: 'i-lucide-type' },
556
+ { kind: 'heading' as const, level: 1 as const, label: t('editor.menu.heading1'), icon: 'i-lucide-heading-1' },
557
+ { kind: 'heading' as const, level: 2 as const, label: t('editor.menu.heading2'), icon: 'i-lucide-heading-2' },
558
+ { kind: 'heading' as const, level: 3 as const, label: t('editor.menu.heading3'), icon: 'i-lucide-heading-3' },
559
+ { kind: 'bulletList' as const, label: t('editor.menu.bulletList'), icon: 'i-lucide-list' },
560
+ { kind: 'orderedList' as const, label: t('editor.menu.orderedList'), icon: 'i-lucide-list-ordered' },
561
+ { kind: 'blockquote' as const, label: t('editor.menu.blockquote'), icon: 'i-lucide-text-quote' },
562
+ { kind: 'codeBlock' as const, label: t('editor.menu.codeBlock'), icon: 'i-lucide-square-code' },
558
563
  ],
559
- [{ type: 'label' as const, label: '삽입' }, ...insertItems],
564
+ [{ type: 'label' as const, label: t('editor.menu.insert') }, ...insertItems],
560
565
  ]
561
566
  })
562
567
 
@@ -564,97 +569,97 @@ const handleItems = (editor: any): DropdownMenuItem[][] => {
564
569
  if (!selectedNode.value?.node?.type) return []
565
570
  return mapEditorItems(editor, [
566
571
  [
567
- { type: 'label', label: '블록 메뉴' },
572
+ { type: 'label', label: t('editor.blockMenu') },
568
573
  {
569
- label: '다른 형식으로 변경',
574
+ label: t('editor.menu.turnInto'),
570
575
  icon: 'i-lucide-repeat-2',
571
576
  children: [
572
- { kind: 'paragraph', label: '본문', icon: 'i-lucide-type' },
573
- { kind: 'heading', level: 1, label: '제목 1', icon: 'i-lucide-heading-1' },
574
- { kind: 'heading', level: 2, label: '제목 2', icon: 'i-lucide-heading-2' },
575
- { kind: 'heading', level: 3, label: '제목 3', icon: 'i-lucide-heading-3' },
576
- { kind: 'bulletList', label: '글머리표 목록', icon: 'i-lucide-list' },
577
- { kind: 'orderedList', label: '번호 목록', icon: 'i-lucide-list-ordered' },
578
- { kind: 'blockquote', label: '인용문', icon: 'i-lucide-text-quote' },
579
- { kind: 'codeBlock', label: '코드 블록', icon: 'i-lucide-square-code' },
577
+ { kind: 'paragraph', label: t('editor.menu.paragraph'), icon: 'i-lucide-type' },
578
+ { kind: 'heading', level: 1, label: t('editor.menu.heading1'), icon: 'i-lucide-heading-1' },
579
+ { kind: 'heading', level: 2, label: t('editor.menu.heading2'), icon: 'i-lucide-heading-2' },
580
+ { kind: 'heading', level: 3, label: t('editor.menu.heading3'), icon: 'i-lucide-heading-3' },
581
+ { kind: 'bulletList', label: t('editor.menu.bulletList'), icon: 'i-lucide-list' },
582
+ { kind: 'orderedList', label: t('editor.menu.orderedList'), icon: 'i-lucide-list-ordered' },
583
+ { kind: 'blockquote', label: t('editor.menu.blockquote'), icon: 'i-lucide-text-quote' },
584
+ { kind: 'codeBlock', label: t('editor.menu.codeBlock'), icon: 'i-lucide-square-code' },
580
585
  ],
581
586
  },
582
- { kind: 'clearFormatting', pos: selectedNode.value.pos, label: '서식 초기화', icon: 'i-lucide-rotate-ccw' },
587
+ { kind: 'clearFormatting', pos: selectedNode.value.pos, label: t('editor.menu.resetFormatting'), icon: 'i-lucide-rotate-ccw' },
583
588
  ],
584
589
  [
585
- { kind: 'duplicate', pos: selectedNode.value.pos, label: '복제', icon: 'i-lucide-copy' },
586
- { kind: 'moveUp', pos: selectedNode.value.pos, label: '위로 이동', icon: 'i-lucide-arrow-up' },
587
- { kind: 'moveDown', pos: selectedNode.value.pos, label: '아래로 이동', icon: 'i-lucide-arrow-down' },
590
+ { kind: 'duplicate', pos: selectedNode.value.pos, label: t('editor.menu.duplicate'), icon: 'i-lucide-copy' },
591
+ { kind: 'moveUp', pos: selectedNode.value.pos, label: t('editor.menu.moveUp'), icon: 'i-lucide-arrow-up' },
592
+ { kind: 'moveDown', pos: selectedNode.value.pos, label: t('editor.menu.moveDown'), icon: 'i-lucide-arrow-down' },
588
593
  ],
589
- [{ kind: 'delete', pos: selectedNode.value.pos, label: '삭제', icon: 'i-lucide-trash' }],
594
+ [{ kind: 'delete', pos: selectedNode.value.pos, label: t('editor.menu.delete'), icon: 'i-lucide-trash' }],
590
595
  ], handlers.value) as DropdownMenuItem[][]
591
596
  }
592
597
 
593
598
  const toolbarItems = computed(() => {
594
599
  const media = isImageEditor.value
595
- ? [{ kind: 'link' as const, icon: 'i-lucide-link', tooltip: { text: '링크' } }]
600
+ ? [{ kind: 'link' as const, icon: 'i-lucide-link', tooltip: { text: t('editor.toolbar.link') } }]
596
601
  : [
597
- { kind: 'link' as const, icon: 'i-lucide-link', tooltip: { text: '링크' } },
598
- { kind: 'image' as const, icon: 'i-lucide-image', tooltip: { text: '이미지' } },
602
+ { kind: 'link' as const, icon: 'i-lucide-link', tooltip: { text: t('editor.toolbar.link') } },
603
+ { kind: 'image' as const, icon: 'i-lucide-image', tooltip: { text: t('editor.toolbar.image') } },
599
604
  { kind: 'youtube' as const, icon: 'i-lucide-youtube', tooltip: { text: 'YouTube' } },
600
605
  ]
601
606
  return [
602
607
  [
603
608
  {
604
609
  icon: 'i-lucide-a-large-small',
605
- tooltip: { text: '서식' },
610
+ tooltip: { text: t('editor.toolbar.format') },
606
611
  content: { align: 'start' as const },
607
612
  items: [
608
613
  [
609
- { kind: 'mark' as const, mark: 'bold' as const, icon: 'i-lucide-bold', label: '굵게' },
610
- { kind: 'mark' as const, mark: 'italic' as const, icon: 'i-lucide-italic', label: '기울임' },
611
- { kind: 'mark' as const, mark: 'underline' as const, icon: 'i-lucide-underline', label: '밑줄' },
612
- { kind: 'mark' as const, mark: 'strike' as const, icon: 'i-lucide-strikethrough', label: '취소선' },
614
+ { kind: 'mark' as const, mark: 'bold' as const, icon: 'i-lucide-bold', label: t('editor.toolbar.bold') },
615
+ { kind: 'mark' as const, mark: 'italic' as const, icon: 'i-lucide-italic', label: t('editor.toolbar.italic') },
616
+ { kind: 'mark' as const, mark: 'underline' as const, icon: 'i-lucide-underline', label: t('editor.toolbar.underline') },
617
+ { kind: 'mark' as const, mark: 'strike' as const, icon: 'i-lucide-strikethrough', label: t('editor.toolbar.strike') },
613
618
  ],
614
619
  [
615
- { kind: 'heading' as const, level: 1 as const, icon: 'i-lucide-heading-1', label: '제목' },
616
- { kind: 'heading' as const, level: 2 as const, icon: 'i-lucide-heading-2', label: '머리말' },
617
- { kind: 'heading' as const, level: 3 as const, icon: 'i-lucide-heading-3', label: '부머리말' },
618
- { kind: 'paragraph' as const, icon: 'i-lucide-text', label: '본문' },
619
- { kind: 'mark' as const, mark: 'code' as const, icon: 'i-lucide-code', label: '모노 스타일' },
620
+ { kind: 'heading' as const, level: 1 as const, icon: 'i-lucide-heading-1', label: t('editor.toolbar.title') },
621
+ { kind: 'heading' as const, level: 2 as const, icon: 'i-lucide-heading-2', label: t('editor.toolbar.heading') },
622
+ { kind: 'heading' as const, level: 3 as const, icon: 'i-lucide-heading-3', label: t('editor.toolbar.subheading') },
623
+ { kind: 'paragraph' as const, icon: 'i-lucide-text', label: t('editor.toolbar.body') },
624
+ { kind: 'mark' as const, mark: 'code' as const, icon: 'i-lucide-code', label: t('editor.toolbar.mono') },
620
625
  ],
621
626
  [
622
- { kind: 'bulletList' as const, icon: 'i-lucide-list', label: '구분점 목록' },
623
- { kind: 'orderedList' as const, icon: 'i-lucide-list-ordered', label: '번호 목록' },
624
- { kind: 'blockquote' as const, icon: 'i-lucide-quote', label: '블록 인용' },
625
- { kind: 'codeBlock' as const, icon: 'i-lucide-square-code', label: '코드 블록' },
627
+ { kind: 'bulletList' as const, icon: 'i-lucide-list', label: t('editor.toolbar.bulletList') },
628
+ { kind: 'orderedList' as const, icon: 'i-lucide-list-ordered', label: t('editor.toolbar.orderedList') },
629
+ { kind: 'blockquote' as const, icon: 'i-lucide-quote', label: t('editor.toolbar.blockquote') },
630
+ { kind: 'codeBlock' as const, icon: 'i-lucide-square-code', label: t('editor.toolbar.codeBlock') },
626
631
  ],
627
632
  [
628
- { kind: 'horizontalRule' as const, icon: 'i-lucide-minus', label: '구분선' },
629
- { kind: 'clearFormatting' as const, icon: 'i-lucide-remove-formatting', label: '서식 지우기' },
633
+ { kind: 'horizontalRule' as const, icon: 'i-lucide-minus', label: t('editor.toolbar.horizontalRule') },
634
+ { kind: 'clearFormatting' as const, icon: 'i-lucide-remove-formatting', label: t('editor.toolbar.clearFormatting') },
630
635
  ],
631
636
  ],
632
637
  },
633
638
  ],
634
639
  media,
635
640
  [
636
- { kind: 'undo' as const, icon: 'i-lucide-undo-2', tooltip: { text: '실행 취소' } },
637
- { kind: 'redo' as const, icon: 'i-lucide-redo-2', tooltip: { text: '다시 실행' } },
641
+ { kind: 'undo' as const, icon: 'i-lucide-undo-2', tooltip: { text: t('editor.toolbar.undo') } },
642
+ { kind: 'redo' as const, icon: 'i-lucide-redo-2', tooltip: { text: t('editor.toolbar.redo') } },
638
643
  ],
639
644
  ]
640
645
  })
641
646
 
642
647
  const aiMenuItems = computed(() => [
643
648
  [
644
- { label: '맞춤법 검사', icon: 'i-lucide-spell-check-2', onSelect: () => runWritingAssistant('proofread') },
645
- { label: '문장 다듬기', icon: 'i-lucide-wand-sparkles', onSelect: () => runWritingAssistant('polish') },
646
- { label: '자연스럽게 이어쓰기', icon: 'i-lucide-text-cursor-input', onSelect: () => runWritingAssistant('continue') },
647
- { label: '짧게 요약하기', icon: 'i-lucide-scan-text', onSelect: () => runWritingAssistant('summarize') },
649
+ { label: t('editor.ai.proofread'), icon: 'i-lucide-spell-check-2', onSelect: () => runWritingAssistant('proofread') },
650
+ { label: t('editor.ai.polish'), icon: 'i-lucide-wand-sparkles', onSelect: () => runWritingAssistant('polish') },
651
+ { label: t('editor.ai.continue'), icon: 'i-lucide-text-cursor-input', onSelect: () => runWritingAssistant('continue') },
652
+ { label: t('editor.ai.summarize'), icon: 'i-lucide-scan-text', onSelect: () => runWritingAssistant('summarize') },
648
653
  ],
649
654
  [
650
- { label: '영어로 번역', icon: 'i-lucide-languages', onSelect: () => runWritingAssistant('translate-en') },
651
- { label: '한국어로 번역', icon: 'i-lucide-languages', onSelect: () => runWritingAssistant('translate-ko') },
655
+ { label: t('editor.ai.translateEn'), icon: 'i-lucide-languages', onSelect: () => runWritingAssistant('translate-en') },
656
+ { label: t('editor.ai.translateKo'), icon: 'i-lucide-languages', onSelect: () => runWritingAssistant('translate-ko') },
652
657
  ],
653
658
  [
654
- { label: '본문에서 제목 만들기', icon: 'i-lucide-heading-1', onSelect: () => runWritingAssistant('title') },
659
+ { label: t('editor.ai.title'), icon: 'i-lucide-heading-1', onSelect: () => runWritingAssistant('title') },
655
660
  ],
656
661
  [
657
- { label: '커스텀 스타일로 바꾸기…', icon: 'i-lucide-pencil-line', onSelect: () => openCustomAssistantDialog() },
662
+ { label: t('editor.ai.custom'), icon: 'i-lucide-pencil-line', onSelect: () => openCustomAssistantDialog() },
658
663
  ],
659
664
  ])
660
665
 
@@ -705,7 +710,7 @@ async function runWritingAssistant(action: WritingAssistantAction, customInstruc
705
710
  }
706
711
  catch (cause) {
707
712
  console.warn('[memi-board] writing assistant failed', cause)
708
- aiError.value = cause instanceof Error ? cause.message : 'AI 작업에 실패했습니다. 다시 시도해 주세요.'
713
+ aiError.value = cause instanceof Error ? cause.message : t('editor.ai.failed')
709
714
  }
710
715
  finally {
711
716
  aiRunning.value = false
@@ -780,7 +785,7 @@ function onImageFormPaste(e: ClipboardEvent) {
780
785
  imageUploadError.value = ''
781
786
  }
782
787
  else {
783
- imageUploadError.value = '사진은 위 영역에 파일로 붙여넣거나 드래그해 주세요.'
788
+ imageUploadError.value = t('editor.errors.pasteAsFile')
784
789
  }
785
790
  }
786
791
  }
@@ -872,7 +877,7 @@ async function loadPost(id: string) {
872
877
  await ensureSettings().catch(() => {})
873
878
  const post = await getPost(id)
874
879
  if (!post) {
875
- error.value = '게시글을 찾을 수 없습니다.'
880
+ error.value = t('editor.errors.postNotFound')
876
881
  title.value = ''
877
882
  content.value = ''
878
883
  coverSlots.value = []
@@ -901,8 +906,8 @@ async function loadPost(id: string) {
901
906
  catch (e) {
902
907
  const msg = (e as Error).message || String(e)
903
908
  error.value = msg.includes('permission')
904
- ? '글을 불러올 권한이 없습니다. 로그인 상태를 확인해 주세요.'
905
- : `글을 불러오지 못했습니다: ${msg}`
909
+ ? t('editor.errors.loadPermission')
910
+ : t('editor.errors.loadFailed', { message: msg })
906
911
  }
907
912
  finally {
908
913
  loading.value = false
@@ -926,43 +931,44 @@ watch(
926
931
  function friendlyWriteError(e: unknown): string {
927
932
  const msg = e instanceof Error ? e.message : String(e)
928
933
  if (msg.includes('permission-denied') || msg.includes('Permission denied')) {
929
- return '수정 권한이 없습니다. 본인이 작성한 글인지, 로그인이 유지되는지 확인해 주세요.'
934
+ return t('editor.errors.editPermission')
930
935
  }
931
936
  return msg
932
937
  }
933
938
 
934
939
  async function handleSubmit() {
940
+ if (saving.value) return
935
941
  error.value = ''
936
942
  const imageBoard = isImageEditor.value
937
943
  if (!imageBoard && !title.value.trim()) {
938
- error.value = '제목을 입력해 주세요.'
944
+ error.value = t('editor.errors.titleRequired')
939
945
  return
940
946
  }
941
947
  // 이미지 보드: 갤러리 1장 이상 (TipTap 본문 이미지 불가)
942
948
  if (imageBoard && coverSlots.value.length === 0) {
943
- error.value = '사진을 한 장 이상 올려 주세요.'
949
+ error.value = t('editor.errors.photoRequired')
944
950
  return
945
951
  }
946
952
  if (!imageBoard && !hasBodyText(content.value) && !hasBodyImage(content.value, attachments.value)) {
947
- error.value = '본문에 글자를 입력하거나 이미지를 첨부해 주세요.'
953
+ error.value = t('editor.errors.bodyRequired')
948
954
  return
949
955
  }
950
956
  if (!user.value) {
951
- error.value = '로그인이 필요합니다.'
957
+ error.value = t('editor.errors.signInRequired')
952
958
  return
953
959
  }
954
960
  if (!resolvedBoardId.value) {
955
- error.value = '게시판(보드)이 지정되지 않았습니다.'
961
+ error.value = t('editor.errors.boardMissing')
956
962
  return
957
963
  }
958
964
  if (isWriteRestricted.value) {
959
965
  error.value = restrictedMessage.value
960
- || '콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.'
966
+ || t('editor.errors.writeRestricted')
961
967
  return
962
968
  }
963
969
 
964
970
  saving.value = true
965
- submitHint.value = '내용을 검토하는 중…'
971
+ submitHint.value = t('editor.hint.reviewing')
966
972
  // 새 글 slug 중복 확인을 검열과 동시에 시작 — 검열에 걸리면 결과만 버린다.
967
973
  const slugPromise = !props.postId && !imageBoard ? resolveUniqueSlug(title.value) : null
968
974
  slugPromise?.catch(() => {})
@@ -971,11 +977,11 @@ async function handleSubmit() {
971
977
  const moderationText = imageBoard ? plain : `${title.value}\n${plain}`
972
978
  const moderation = await checkText(moderationText, { skipFilter: isAdmin.value && adminSkipModeration.value })
973
979
  if (moderation.flagged) {
974
- error.value = moderation.reason || '게시할 수 없는 내용이 포함되어 있습니다.'
980
+ error.value = moderation.reason || t('editor.errors.moderationBlocked')
975
981
  return
976
982
  }
977
983
 
978
- submitHint.value = '저장하는 중…'
984
+ submitHint.value = t('editor.hint.saving')
979
985
  const bodyContent = imageBoard && coverSlots.value.length
980
986
  ? withCoverImagesInContent(content.value, coverUrls())
981
987
  : content.value
@@ -1016,6 +1022,7 @@ async function handleSubmit() {
1016
1022
  <template>
1017
1023
  <div
1018
1024
  v-if="loading"
1025
+ :dir="dir"
1019
1026
  class="flex flex-col gap-3"
1020
1027
  >
1021
1028
  <USkeleton class="h-10 w-full" />
@@ -1024,6 +1031,7 @@ async function handleSubmit() {
1024
1031
 
1025
1032
  <form
1026
1033
  v-else
1034
+ :dir="dir"
1027
1035
  class="flex flex-col gap-4"
1028
1036
  @submit.prevent="handleSubmit"
1029
1037
  @paste.capture="onImageFormPaste"
@@ -1033,7 +1041,7 @@ async function handleSubmit() {
1033
1041
  <UInput
1034
1042
  v-if="!isImageEditor"
1035
1043
  v-model="title"
1036
- placeholder="제목"
1044
+ :placeholder="t('editor.titlePlaceholder')"
1037
1045
  size="lg"
1038
1046
  required
1039
1047
  @keydown.tab.exact.prevent="focusContentFromTitle"
@@ -1045,7 +1053,7 @@ async function handleSubmit() {
1045
1053
  >
1046
1054
  <div class="flex items-center justify-between gap-2">
1047
1055
  <p class="text-xs text-muted">
1048
- 사진 {{ coverSlots.length }}/{{ COVER_IMAGE_MAX }} · 대표사진 선택 · 끌어 순서 변경 · 파일 드롭/붙여넣기로 추가
1056
+ {{ t('editor.cover.hint', { count: coverSlots.length, max: COVER_IMAGE_MAX }) }}
1049
1057
  </p>
1050
1058
  <div class="flex shrink-0 items-center gap-1">
1051
1059
  <UButton
@@ -1053,7 +1061,7 @@ async function handleSubmit() {
1053
1061
  size="xs"
1054
1062
  color="neutral"
1055
1063
  variant="ghost"
1056
- label="이미지 링크"
1064
+ :label="t('editor.cover.imageLink')"
1057
1065
  icon="i-lucide-link"
1058
1066
  :disabled="imageUploading || coverSlots.length >= COVER_IMAGE_MAX"
1059
1067
  @click="openImageDialog()"
@@ -1064,7 +1072,7 @@ async function handleSubmit() {
1064
1072
  size="xs"
1065
1073
  color="neutral"
1066
1074
  variant="ghost"
1067
- label="사진 추가"
1075
+ :label="t('editor.cover.addPhoto')"
1068
1076
  icon="i-lucide-plus"
1069
1077
  :disabled="imageUploading || coverSlots.length >= COVER_IMAGE_MAX"
1070
1078
  @click="pickCoverImages"
@@ -1102,7 +1110,7 @@ async function handleSubmit() {
1102
1110
  >
1103
1111
  <img
1104
1112
  :src="slot.url"
1105
- :alt="`사진 ${index + 1}`"
1113
+ :alt="t('editor.cover.alt', { n: index + 1 })"
1106
1114
  class="pointer-events-none size-full object-cover"
1107
1115
  draggable="false"
1108
1116
  >
@@ -1110,18 +1118,18 @@ async function handleSubmit() {
1110
1118
  v-if="index === 0"
1111
1119
  class="pointer-events-none absolute left-1 top-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white"
1112
1120
  >
1113
- 대표
1121
+ {{ t('editor.cover.representative') }}
1114
1122
  </span>
1115
1123
  <button
1116
1124
  v-else
1117
1125
  type="button"
1118
1126
  class="absolute left-1 top-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white opacity-100 transition hover:bg-primary sm:opacity-0 sm:group-hover:opacity-100"
1119
1127
  :disabled="imageUploading"
1120
- :aria-label="`사진 ${index + 1}을 대표사진으로 설정`"
1128
+ :aria-label="t('editor.cover.setRepresentativeAria', { n: index + 1 })"
1121
1129
  @click.stop="setRepresentativeCover(index)"
1122
1130
  @dragstart.stop.prevent
1123
1131
  >
1124
- 대표로
1132
+ {{ t('editor.cover.setRepresentative') }}
1125
1133
  </button>
1126
1134
  <span
1127
1135
  class="pointer-events-none absolute bottom-1 left-1 rounded bg-black/50 px-1 py-0.5 text-[10px] text-white/90"
@@ -1133,7 +1141,7 @@ async function handleSubmit() {
1133
1141
  type="button"
1134
1142
  class="absolute right-1 top-1 flex size-7 items-center justify-center rounded-full bg-black/60 text-white opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100"
1135
1143
  :disabled="imageUploading"
1136
- aria-label="사진 제거"
1144
+ :aria-label="t('editor.cover.remove')"
1137
1145
  @click.stop="removeCoverAt(index)"
1138
1146
  @dragstart.stop.prevent
1139
1147
  >
@@ -1154,7 +1162,7 @@ async function handleSubmit() {
1154
1162
  class="size-6"
1155
1163
  :class="imageUploading ? 'animate-spin' : ''"
1156
1164
  />
1157
- <span class="text-[11px] font-medium">추가</span>
1165
+ <span class="text-[11px] font-medium">{{ t('editor.cover.add') }}</span>
1158
1166
  </button>
1159
1167
  </div>
1160
1168
 
@@ -1171,10 +1179,10 @@ async function handleSubmit() {
1171
1179
  :class="imageUploading ? 'animate-spin' : ''"
1172
1180
  />
1173
1181
  <span class="text-sm font-medium text-highlighted">
1174
- {{ imageUploading ? '업로드 중…' : '사진을 놓거나 클릭해서 추가' }}
1182
+ {{ imageUploading ? t('editor.cover.uploading') : t('editor.cover.dropOrClick') }}
1175
1183
  </span>
1176
1184
  <span class="text-xs text-muted">
1177
- 여러 장 선택 가능 · 업로드 후 대표사진 선택 · 끌어 순서 변경
1185
+ {{ t('editor.cover.emptyHint') }}
1178
1186
  </span>
1179
1187
  </button>
1180
1188
  </div>
@@ -1184,7 +1192,7 @@ async function handleSubmit() {
1184
1192
  color="error"
1185
1193
  variant="subtle"
1186
1194
  icon="i-lucide-circle-alert"
1187
- title="업로드"
1195
+ :title="t('editor.cover.uploadTitle')"
1188
1196
  :description="imageUploadError"
1189
1197
  />
1190
1198
  </div>
@@ -1200,7 +1208,7 @@ async function handleSubmit() {
1200
1208
  color="error"
1201
1209
  variant="subtle"
1202
1210
  icon="i-lucide-circle-alert"
1203
- title="업로드 실패"
1211
+ :title="t('editor.uploadFailed')"
1204
1212
  :description="imageUploadError"
1205
1213
  class="rounded-none rounded-t-xl"
1206
1214
  />
@@ -1211,7 +1219,7 @@ async function handleSubmit() {
1211
1219
  content-type="html"
1212
1220
  :extensions="editorExtensions"
1213
1221
  :handlers="handlers"
1214
- :placeholder="isImageEditor ? '내용을 입력하세요…' : '본문을 입력하세요… / 를 입력하면 블록 메뉴가 열립니다.'"
1222
+ :placeholder="isImageEditor ? t('editor.imageContentPlaceholder') : t('editor.contentPlaceholder')"
1215
1223
  :ui="{ content: 'min-h-64 p-4' }"
1216
1224
  class="board-content w-full"
1217
1225
  >
@@ -1232,7 +1240,7 @@ async function handleSubmit() {
1232
1240
  class="m-2 shrink-0"
1233
1241
  :loading="aiRunning"
1234
1242
  :disabled="aiRunning"
1235
- aria-label="AI 글쓰기 도우미"
1243
+ :aria-label="t('editor.aiAssistant')"
1236
1244
  />
1237
1245
  </UDropdownMenu>
1238
1246
  </div>
@@ -1245,7 +1253,7 @@ async function handleSubmit() {
1245
1253
  variant="ghost"
1246
1254
  size="sm"
1247
1255
  :class="ui.handle()"
1248
- aria-label="블록 삽입"
1256
+ :aria-label="t('editor.insertBlock')"
1249
1257
  @click="(event: MouseEvent) => {
1250
1258
  event.stopPropagation()
1251
1259
  const selected = onClick()
@@ -1268,7 +1276,7 @@ async function handleSubmit() {
1268
1276
  icon="i-lucide-grip-vertical"
1269
1277
  :active="open"
1270
1278
  :class="ui.handle()"
1271
- aria-label="블록 메뉴"
1279
+ :aria-label="t('editor.blockMenu')"
1272
1280
  />
1273
1281
  </UDropdownMenu>
1274
1282
  </UEditorDragHandle>
@@ -1291,13 +1299,13 @@ async function handleSubmit() {
1291
1299
  name="i-lucide-loader-circle"
1292
1300
  class="size-3.5 animate-spin"
1293
1301
  />
1294
- 이미지 최적화 및 업로드 중…
1302
+ {{ t('editor.imageOptimizing') }}
1295
1303
  </p>
1296
1304
  </div>
1297
1305
 
1298
1306
  <UInputTags
1299
1307
  v-model="tags"
1300
- placeholder="태그 입력 후 Enter"
1308
+ :placeholder="t('editor.tagsPlaceholder')"
1301
1309
  :delimiter="','"
1302
1310
  add-on-paste
1303
1311
  />
@@ -1336,7 +1344,7 @@ async function handleSubmit() {
1336
1344
  <USwitch
1337
1345
  v-model="adminSkipModeration"
1338
1346
  size="sm"
1339
- label="비속어 필터 무시 (관리자)"
1347
+ :label="t('editor.skipModeration')"
1340
1348
  />
1341
1349
  </div>
1342
1350
 
@@ -1345,13 +1353,15 @@ async function handleSubmit() {
1345
1353
  type="submit"
1346
1354
  :loading="saving"
1347
1355
  :disabled="isWriteRestricted || imageUploading"
1348
- :label="isEdit ? '수정 완료' : '다음: 미리보기'"
1356
+ :label="isEdit ? t('editor.submitEdit') : t('editor.submitNext')"
1357
+ @pointerdown="imeSafeSubmitPointerDown"
1358
+ @click="imeSafeSubmitClick"
1349
1359
  />
1350
1360
  <UButton
1351
1361
  type="button"
1352
1362
  variant="ghost"
1353
1363
  color="neutral"
1354
- label="취소"
1364
+ :label="t('common.action.cancel')"
1355
1365
  @click="emit('cancel')"
1356
1366
  />
1357
1367
  </div>
@@ -1359,7 +1369,7 @@ async function handleSubmit() {
1359
1369
 
1360
1370
  <UModal
1361
1371
  v-model:open="imageDialogOpen"
1362
- title="이미지 추가"
1372
+ :title="t('editor.imageDialog.title')"
1363
1373
  :ui="{ content: 'sm:max-w-lg' }"
1364
1374
  >
1365
1375
  <template #body>
@@ -1369,7 +1379,7 @@ async function handleSubmit() {
1369
1379
  color="neutral"
1370
1380
  variant="outline"
1371
1381
  icon="i-lucide-upload"
1372
- label="내 기기에서 이미지 선택"
1382
+ :label="t('editor.imageDialog.chooseFile')"
1373
1383
  block
1374
1384
  :disabled="imageUploading"
1375
1385
  @click="chooseImageFileFromDialog"
@@ -1377,13 +1387,13 @@ async function handleSubmit() {
1377
1387
 
1378
1388
  <div class="flex items-center gap-3 text-xs text-muted">
1379
1389
  <span class="h-px flex-1 bg-default" />
1380
- 또는 이미지 링크
1390
+ {{ t('editor.imageDialog.orLink') }}
1381
1391
  <span class="h-px flex-1 bg-default" />
1382
1392
  </div>
1383
1393
 
1384
1394
  <UFormField
1385
- label="이미지 URL"
1386
- help="HTTPS 주소만 사용할 수 있으며 원본 사이트에서 삭제되면 이미지도 표시되지 않습니다."
1395
+ :label="t('editor.imageDialog.urlLabel')"
1396
+ :help="t('editor.imageDialog.urlHelp')"
1387
1397
  >
1388
1398
  <UInput
1389
1399
  v-model="externalImageUrl"
@@ -1402,7 +1412,7 @@ async function handleSubmit() {
1402
1412
  >
1403
1413
  <img
1404
1414
  :src="externalImageCandidate"
1405
- alt="외부 이미지 미리보기"
1415
+ :alt="t('editor.imageDialog.previewAlt')"
1406
1416
  referrerpolicy="no-referrer"
1407
1417
  class="max-h-64 max-w-full object-contain"
1408
1418
  >
@@ -1427,7 +1437,7 @@ async function handleSubmit() {
1427
1437
  :disabled="externalImageChecking"
1428
1438
  @click="imageDialogOpen = false"
1429
1439
  >
1430
- 취소
1440
+ {{ t('common.action.cancel') }}
1431
1441
  </UButton>
1432
1442
  <UButton
1433
1443
  type="button"
@@ -1436,7 +1446,7 @@ async function handleSubmit() {
1436
1446
  :disabled="!externalImageCandidate"
1437
1447
  @click="addExternalImage"
1438
1448
  >
1439
- 링크 추가
1449
+ {{ t('editor.imageDialog.addLink') }}
1440
1450
  </UButton>
1441
1451
  </div>
1442
1452
  </template>
@@ -1444,17 +1454,17 @@ async function handleSubmit() {
1444
1454
 
1445
1455
  <UModal
1446
1456
  v-model:open="aiCustomDialogOpen"
1447
- title="커스텀 스타일로 바꾸기"
1457
+ :title="t('editor.customDialog.title')"
1448
1458
  :ui="{ content: 'sm:max-w-lg' }"
1449
1459
  >
1450
1460
  <template #body>
1451
1461
  <UFormField
1452
- label="어떻게 바꿀까요?"
1453
- help="예: 경상도 사투리 스타일로 / 정중한 문어체로 / 간결한 개조식으로"
1462
+ :label="t('editor.customDialog.label')"
1463
+ :help="t('editor.customDialog.help')"
1454
1464
  >
1455
1465
  <UTextarea
1456
1466
  v-model="aiCustomInstruction"
1457
- placeholder="경상도 사투리 스타일로"
1467
+ :placeholder="t('editor.customDialog.placeholder')"
1458
1468
  autocomplete="off"
1459
1469
  class="w-full"
1460
1470
  :rows="2"
@@ -1472,7 +1482,7 @@ async function handleSubmit() {
1472
1482
  variant="ghost"
1473
1483
  @click="aiCustomDialogOpen = false"
1474
1484
  >
1475
- 취소
1485
+ {{ t('common.action.cancel') }}
1476
1486
  </UButton>
1477
1487
  <UButton
1478
1488
  type="button"
@@ -1480,7 +1490,7 @@ async function handleSubmit() {
1480
1490
  :disabled="!aiCustomInstruction.trim()"
1481
1491
  @click="submitCustomAssistant"
1482
1492
  >
1483
- 적용
1493
+ {{ t('editor.apply') }}
1484
1494
  </UButton>
1485
1495
  </div>
1486
1496
  </template>
@@ -1488,7 +1498,7 @@ async function handleSubmit() {
1488
1498
 
1489
1499
  <UModal
1490
1500
  v-model:open="aiPreviewOpen"
1491
- title="AI 결과 확인"
1501
+ :title="t('editor.aiPreview.title')"
1492
1502
  :ui="{ content: 'sm:max-w-4xl lg:max-w-6xl' }"
1493
1503
  >
1494
1504
  <template #body>
@@ -1530,14 +1540,14 @@ async function handleSubmit() {
1530
1540
  variant="ghost"
1531
1541
  @click="closeAiPreview"
1532
1542
  >
1533
- 취소
1543
+ {{ t('common.action.cancel') }}
1534
1544
  </UButton>
1535
1545
  <UButton
1536
1546
  type="button"
1537
1547
  icon="i-lucide-check"
1538
1548
  @click="applyAiPreview"
1539
1549
  >
1540
- 적용
1550
+ {{ t('editor.apply') }}
1541
1551
  </UButton>
1542
1552
  </div>
1543
1553
  </template>