memi-board 0.46.0 → 0.46.2

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 (53) hide show
  1. package/README.md +1 -0
  2. package/dist/{ar-BCQdRw_S.js → ar-D_6hknjh.js} +22 -1
  3. package/dist/ar-D_6hknjh.js.map +1 -0
  4. package/dist/composables/fetchPublicSeo.d.ts +1 -1
  5. package/dist/composables/useMemiBoardAutoTranslate.d.ts +6 -6
  6. package/dist/composables/useMemiBoardPosts.d.ts +6 -1
  7. package/dist/composables/useMemiBoardSettings.d.ts +5 -4
  8. package/dist/composables/useMemiBoardWritingAssistant.d.ts +8 -0
  9. package/dist/{de-ddWQNaqs.js → de-DQqSsBsZ.js} +22 -1
  10. package/dist/de-DQqSsBsZ.js.map +1 -0
  11. package/dist/{en-CaPfPNDy.js → en-Broh-9df.js} +19 -1
  12. package/dist/en-Broh-9df.js.map +1 -0
  13. package/dist/{es-7JHjGVlM.js → es-D3eEYN6w.js} +22 -1
  14. package/dist/es-D3eEYN6w.js.map +1 -0
  15. package/dist/{fr-C3NCMNoy.js → fr-CbXPRKeU.js} +22 -1
  16. package/dist/fr-CbXPRKeU.js.map +1 -0
  17. package/dist/{id-C8hCAMPJ.js → id-DxVqvPpo.js} +22 -1
  18. package/dist/id-DxVqvPpo.js.map +1 -0
  19. package/dist/index.d.ts +3 -2
  20. package/dist/index.js +1266 -921
  21. package/dist/index.js.map +1 -1
  22. package/dist/{ja-oTWri4hY.js → ja-D2dIWUse.js} +22 -1
  23. package/dist/{ja-oTWri4hY.js.map → ja-D2dIWUse.js.map} +1 -1
  24. package/dist/{pt-B9EH4scA.js → pt-BAuHdglU.js} +22 -1
  25. package/dist/pt-BAuHdglU.js.map +1 -0
  26. package/dist/runtime/components/CategorySettings.vue +80 -14
  27. package/dist/runtime/components/Detail.vue +9 -7
  28. package/dist/runtime/components/Editor.vue +79 -8
  29. package/dist/runtime/components/List.vue +2 -2
  30. package/dist/runtime/components/ListDefault.vue +16 -6
  31. package/dist/runtime/components/ListDense.vue +8 -4
  32. package/dist/runtime/components/ListImage.vue +13 -6
  33. package/dist/runtime/components/ListVideo.vue +14 -5
  34. package/dist/runtime/components/Section.vue +10 -3
  35. package/dist/runtime/components/Settings.vue +77 -11
  36. package/dist/runtime/composables/useMemiBoardSeo.ts +3 -3
  37. package/dist/{slugify-DJ82ifCd.js → slugify-B05Qykf_.js} +28 -10
  38. package/dist/slugify-B05Qykf_.js.map +1 -0
  39. package/dist/storage.js +1 -1
  40. package/dist/types.d.ts +48 -0
  41. package/dist/utils/localizedFields.d.ts +33 -0
  42. package/dist/{zh-D5rDYV6E.js → zh-CMyzTdQ5.js} +22 -1
  43. package/dist/zh-CMyzTdQ5.js.map +1 -0
  44. package/package.json +1 -1
  45. package/dist/ar-BCQdRw_S.js.map +0 -1
  46. package/dist/de-ddWQNaqs.js.map +0 -1
  47. package/dist/en-CaPfPNDy.js.map +0 -1
  48. package/dist/es-7JHjGVlM.js.map +0 -1
  49. package/dist/fr-C3NCMNoy.js.map +0 -1
  50. package/dist/id-C8hCAMPJ.js.map +0 -1
  51. package/dist/pt-B9EH4scA.js.map +0 -1
  52. package/dist/slugify-DJ82ifCd.js.map +0 -1
  53. package/dist/zh-D5rDYV6E.js.map +0 -1
@@ -11,7 +11,7 @@ import { imeSafeSubmitClick, imeSafeSubmitPointerDown } from 'memi-board/runtime
11
11
  import Youtube from '@tiptap/extension-youtube'
12
12
  import type { DropdownMenuItem, EditorSuggestionMenuItem } from '@nuxt/ui'
13
13
  import { mapEditorItems } from '@nuxt/ui/utils/editor'
14
- import type { Attachment, EditorImageEntry } from 'memi-board/runtime'
14
+ import type { Attachment, EditorImageEntry, PostTranslationSet } from 'memi-board/runtime'
15
15
  import type { WritingAssistantAction } from 'memi-board/runtime'
16
16
  import {
17
17
  useMemiBoardAuth,
@@ -22,6 +22,9 @@ import {
22
22
  hasBodyImage,
23
23
  hasBodyText,
24
24
  plainTextFromHtml,
25
+ titleFromBody,
26
+ buildPostPreview,
27
+ hashText,
25
28
  useMemiBoardWritingAssistant,
26
29
  } from 'memi-board/runtime'
27
30
  import { useMemiBoardStorage, EDITOR_IMAGE_SOURCE_MAX_BYTES } from 'memi-board/storage'
@@ -39,14 +42,14 @@ const emit = defineEmits<{ saved: [id: string], cancel: [] }>()
39
42
 
40
43
  const { user, isSignedIn, isAdmin, isWriteRestricted, restrictedMessage } = useMemiBoardAuth()
41
44
  const resolvedBoardId = computed(() => props.boardId || props.fixedCategory || '')
42
- const { createPostId, getPost, createPost, updatePost, resolveUniqueSlug } = useMemiBoardPosts(resolvedBoardId)
45
+ const { createPostId, getPost, createPost, updatePost, savePostTranslations, resolveUniqueSlug } = useMemiBoardPosts(resolvedBoardId)
43
46
  const { getBoard, ensureSettings } = useMemiBoardSettings()
44
47
  // 보드 설정 언어 → 호스트 언어 순.
45
48
  const { t, dir, locale } = useMemiBoardI18n({ boardLocale: () => getBoard(resolvedBoardId.value)?.locale })
46
49
  // provide 한 컴포넌트 자신은 inject 를 못 받으므로 AI 프롬프트 언어를 직접 넘긴다.
47
50
  const { checkText } = useMemiBoardModeration({ locale })
48
51
  const { uploadEditorImage } = useMemiBoardStorage()
49
- const { assist } = useMemiBoardWritingAssistant({ locale })
52
+ const { assist, translatePost } = useMemiBoardWritingAssistant({ locale })
50
53
 
51
54
  const title = ref('')
52
55
  /** UEditor content-type=html (shineb 와 동일) */
@@ -78,6 +81,9 @@ const externalImageCandidate = computed(() => {
78
81
  })
79
82
  const aiRunning = ref(false)
80
83
  const aiError = ref('')
84
+ const aiNotice = ref('')
85
+ const pendingTranslations = ref<PostTranslationSet | null>(null)
86
+ const pendingTranslationKey = ref('')
81
87
  const aiCustomDialogOpen = ref(false)
82
88
  const aiCustomInstruction = ref('')
83
89
 
@@ -652,8 +658,7 @@ const aiMenuItems = computed(() => [
652
658
  { label: t('editor.ai.summarize'), icon: 'i-lucide-scan-text', onSelect: () => runWritingAssistant('summarize') },
653
659
  ],
654
660
  [
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') },
661
+ { label: t('editor.ai.translateAll'), icon: 'i-lucide-languages', onSelect: () => translateAllLocales() },
657
662
  ],
658
663
  [
659
664
  { label: t('editor.ai.title'), icon: 'i-lucide-heading-1', onSelect: () => runWritingAssistant('title') },
@@ -684,6 +689,7 @@ async function runWritingAssistant(action: WritingAssistantAction, customInstruc
684
689
  const editor = resolveEditor()
685
690
  if (!editor || aiRunning.value) return
686
691
  aiError.value = ''
692
+ aiNotice.value = ''
687
693
 
688
694
  const { from, to } = editor.state.selection
689
695
  const hasSelection = from !== to
@@ -717,6 +723,60 @@ async function runWritingAssistant(action: WritingAssistantAction, customInstruc
717
723
  }
718
724
  }
719
725
 
726
+ function currentBodyContent(): string {
727
+ return isImageEditor.value && coverSlots.value.length
728
+ ? withCoverImagesInContent(content.value, coverUrls())
729
+ : content.value
730
+ }
731
+
732
+ function currentStoredTitle(body: string): string {
733
+ return isImageEditor.value ? titleFromBody(body) : title.value.trim()
734
+ }
735
+
736
+ function translationKey(body: string, storedTitle: string): string {
737
+ return `${storedTitle}\n${hashText(body)}`
738
+ }
739
+
740
+ async function translateAllLocales() {
741
+ if (aiRunning.value) return
742
+ aiError.value = ''
743
+ aiNotice.value = ''
744
+ const body = currentBodyContent()
745
+ const storedTitle = currentStoredTitle(body)
746
+ const summary = buildPostPreview(body, isImageEditor.value ? [] : attachments.value).summary
747
+ if (!storedTitle.trim() && !summary.trim() && !plainTextFromHtml(body)) {
748
+ aiError.value = t('editor.ai.translateEmpty')
749
+ return
750
+ }
751
+ aiRunning.value = true
752
+ try {
753
+ const translations = await translatePost({
754
+ source: locale.value,
755
+ title: storedTitle,
756
+ summary,
757
+ content: plainTextFromHtml(body) ? body : '',
758
+ })
759
+ if (props.postId) {
760
+ await savePostTranslations(props.postId, translations)
761
+ pendingTranslations.value = null
762
+ pendingTranslationKey.value = ''
763
+ aiNotice.value = t('editor.ai.translateSaved')
764
+ }
765
+ else {
766
+ pendingTranslations.value = translations
767
+ pendingTranslationKey.value = translationKey(body, storedTitle)
768
+ aiNotice.value = t('editor.ai.translatePending')
769
+ }
770
+ }
771
+ catch (cause) {
772
+ console.warn('[memi-board] post translation failed', cause)
773
+ aiError.value = cause instanceof Error ? cause.message : t('editor.ai.failed')
774
+ }
775
+ finally {
776
+ aiRunning.value = false
777
+ }
778
+ }
779
+
720
780
  function closeAiPreview() {
721
781
  aiPreviewOpen.value = false
722
782
  aiPreviewPending.value = null
@@ -982,15 +1042,19 @@ async function handleSubmit() {
982
1042
  }
983
1043
 
984
1044
  submitHint.value = t('editor.hint.saving')
985
- const bodyContent = imageBoard && coverSlots.value.length
986
- ? withCoverImagesInContent(content.value, coverUrls())
987
- : content.value
1045
+ const bodyContent = currentBodyContent()
1046
+ const storedTitle = currentStoredTitle(bodyContent)
1047
+ const translations = pendingTranslations.value
1048
+ && pendingTranslationKey.value === translationKey(bodyContent, storedTitle)
1049
+ ? pendingTranslations.value
1050
+ : undefined
988
1051
  const payload = {
989
1052
  title: imageBoard ? '' : title.value.trim(),
990
1053
  content: bodyContent,
991
1054
  tags: tags.value.map(t => t.trim()).filter(Boolean),
992
1055
  // 이미지 보드: 파일 첨부 없음 (커버는 본문 앞 img 로 저장)
993
1056
  attachments: imageBoard ? [] : attachments.value,
1057
+ ...(translations ? { translations } : {}),
994
1058
  }
995
1059
 
996
1060
  if (props.postId) {
@@ -1290,6 +1354,13 @@ async function handleSubmit() {
1290
1354
  <UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
1291
1355
  {{ aiError }}
1292
1356
  </p>
1357
+ <p
1358
+ v-else-if="aiNotice"
1359
+ class="flex items-center gap-2 border-t border-default px-4 py-2 text-xs text-primary"
1360
+ >
1361
+ <UIcon name="i-lucide-languages" class="size-3.5 shrink-0" />
1362
+ {{ aiNotice }}
1363
+ </p>
1293
1364
 
1294
1365
  <p
1295
1366
  v-if="imageUploading && !isImageEditor"
@@ -64,12 +64,12 @@ const emit = defineEmits<{ select: [post: PostModel], 'update:view': [view: Boar
64
64
  const { boardDescription, getBoard } = useMemiBoardSettings()
65
65
  const resolvedBoardId = computed(() => props.boardId || props.category || '')
66
66
  // 보드 설정 언어 → 호스트 언어 순. 자식 컴포넌트도 이 언어를 따른다.
67
- const { t, dir } = useMemiBoardI18n({ boardLocale: () => getBoard(resolvedBoardId.value)?.locale })
67
+ const { t, dir, locale } = useMemiBoardI18n({ boardLocale: () => getBoard(resolvedBoardId.value)?.locale })
68
68
  const introductionText = computed(() => props.introduction ?? t('list.introduction'))
69
69
  const listDescription = computed(() => {
70
70
  const explicit = props.description?.trim()
71
71
  if (explicit) return explicit
72
- if (resolvedBoardId.value) return boardDescription(resolvedBoardId.value)
72
+ if (resolvedBoardId.value) return boardDescription(resolvedBoardId.value, locale.value)
73
73
  return undefined
74
74
  })
75
75
  const { posts, postsPending, hasMore, loadingMore, loadError, loadMore } = useMemiBoardPostList(
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { resolveComponent } from 'vue'
3
3
  import type { PostModel } from 'memi-board/runtime'
4
- import { useMemiBoardI18n, useMemiBoardSettings } from 'memi-board/runtime'
4
+ import { displayPostSummary, displayPostTitle, useMemiBoardI18n, useMemiBoardSettings } from 'memi-board/runtime'
5
5
  import MemiBoardAuthorMenu from './AuthorMenu.vue'
6
6
 
7
7
  withDefaults(defineProps<{
@@ -15,7 +15,17 @@ withDefaults(defineProps<{
15
15
  }>(), { showCategory: true })
16
16
  const emit = defineEmits<{ select: [post: PostModel] }>()
17
17
  const { categoryLabel } = useMemiBoardSettings()
18
- const { t, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n()
18
+ const { t, locale, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n()
19
+
20
+ function titleOf(post: PostModel) {
21
+ return displayPostTitle(post, locale.value).trim() || displayPostSummary(post, locale.value).trim() || t('list.photo')
22
+ }
23
+ function summaryOf(post: PostModel) {
24
+ const summary = displayPostSummary(post, locale.value).trim()
25
+ const title = displayPostTitle(post, locale.value).trim()
26
+ if (!summary || summary === title) return ''
27
+ return summary
28
+ }
19
29
  const NuxtLink = resolveComponent('NuxtLink')
20
30
 
21
31
  function image(post: PostModel): string | undefined {
@@ -58,7 +68,7 @@ function image(post: PostModel): string | undefined {
58
68
  class="mt-0.5 shrink-0"
59
69
  />
60
70
  <h3 class="min-w-0 line-clamp-2 break-words text-sm font-medium leading-snug text-inherit sm:text-base">
61
- {{ post.title?.trim() || post.summary || t('list.photo') }}
71
+ {{ titleOf(post) }}
62
72
  </h3>
63
73
  <span
64
74
  v-if="(post.commentCount ?? 0) > 0"
@@ -69,10 +79,10 @@ function image(post: PostModel): string | undefined {
69
79
  </div>
70
80
  </div>
71
81
  <p
72
- v-if="post.summary"
82
+ v-if="summaryOf(post)"
73
83
  class="min-w-0 line-clamp-2 break-words whitespace-pre-line text-sm leading-snug text-muted"
74
84
  >
75
- {{ post.summary }}
85
+ {{ summaryOf(post) }}
76
86
  </p>
77
87
  </div>
78
88
 
@@ -82,7 +92,7 @@ function image(post: PostModel): string | undefined {
82
92
  >
83
93
  <img
84
94
  :src="image(post)"
85
- :alt="post.title"
95
+ :alt="titleOf(post)"
86
96
  class="h-full w-full object-cover"
87
97
  >
88
98
  </div>
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { resolveComponent } from 'vue'
3
3
  import type { PostModel } from 'memi-board/runtime'
4
- import { useMemiBoardI18n, useMemiBoardSettings } from 'memi-board/runtime'
4
+ import { displayPostSummary, displayPostTitle, useMemiBoardI18n, useMemiBoardSettings } from 'memi-board/runtime'
5
5
  import MemiBoardAuthorMenu from './AuthorMenu.vue'
6
6
 
7
7
  withDefaults(defineProps<{
@@ -15,7 +15,11 @@ withDefaults(defineProps<{
15
15
  }>(), { showCategory: true })
16
16
  const emit = defineEmits<{ select: [post: PostModel] }>()
17
17
  const { categoryLabel } = useMemiBoardSettings()
18
- const { t, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n()
18
+ const { t, locale, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n()
19
+
20
+ function titleOf(post: PostModel) {
21
+ return displayPostTitle(post, locale.value).trim() || displayPostSummary(post, locale.value).trim() || t('list.photo')
22
+ }
19
23
  const NuxtLink = resolveComponent('NuxtLink')
20
24
 
21
25
  function thumb(post: PostModel): string | undefined {
@@ -44,7 +48,7 @@ function thumb(post: PostModel): string | undefined {
44
48
  class="shrink-0"
45
49
  />
46
50
  <h3 class="min-w-0 truncate text-sm font-medium leading-snug text-inherit transition-colors group-hover:text-primary">
47
- {{ post.title?.trim() || post.summary || t('list.photo') }}
51
+ {{ titleOf(post) }}
48
52
  </h3>
49
53
  <span
50
54
  v-if="(post.commentCount ?? 0) > 0"
@@ -113,7 +117,7 @@ function thumb(post: PostModel): string | undefined {
113
117
  >
114
118
  <img
115
119
  :src="thumb(post)"
116
- :alt="post.title?.trim() || post.summary || t('list.photo')"
120
+ :alt="titleOf(post)"
117
121
  class="size-full object-cover"
118
122
  >
119
123
  </component>
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { resolveComponent } from 'vue'
3
3
  import type { PostModel } from 'memi-board/runtime'
4
- import { useMemiBoardI18n } from 'memi-board/runtime'
4
+ import { displayPostSummary, displayPostTitle, useMemiBoardI18n } from 'memi-board/runtime'
5
5
 
6
6
  defineProps<{
7
7
  posts: PostModel[]
@@ -10,7 +10,14 @@ defineProps<{
10
10
  }>()
11
11
  const emit = defineEmits<{ select: [post: PostModel] }>()
12
12
  const NuxtLink = resolveComponent('NuxtLink')
13
- const { t } = useMemiBoardI18n()
13
+ const { t, locale } = useMemiBoardI18n()
14
+
15
+ function titleOf(post: PostModel) {
16
+ return displayPostTitle(post, locale.value).trim()
17
+ }
18
+ function altOf(post: PostModel) {
19
+ return titleOf(post) || displayPostSummary(post, locale.value).trim() || t('common.label.image')
20
+ }
14
21
 
15
22
  function image(post: PostModel): string | undefined {
16
23
  return post.previewImage || post.attachments?.find(item => item.type.startsWith('image/'))?.url
@@ -30,7 +37,7 @@ function image(post: PostModel): string | undefined {
30
37
  <img
31
38
  v-if="image(post)"
32
39
  :src="image(post)"
33
- :alt="post.title?.trim() || post.summary || t('common.label.image')"
40
+ :alt="altOf(post)"
34
41
  class="size-full object-cover transition duration-300 group-hover:scale-105"
35
42
  >
36
43
  <div
@@ -47,7 +54,7 @@ function image(post: PostModel): string | undefined {
47
54
 
48
55
  <!-- 좌측 상단: 초안 표시 + 제목(있을 때만) + 댓글수. 이미지 보드는 제목 없이 썸네일 중심 -->
49
56
  <div
50
- v-if="post.isPublished === false || post.title?.trim() || (post.commentCount ?? 0) > 0"
57
+ v-if="post.isPublished === false || titleOf(post) || (post.commentCount ?? 0) > 0"
51
58
  class="absolute left-2.5 top-2.5 right-14 flex min-w-0 items-start gap-1.5 sm:left-3 sm:top-3"
52
59
  >
53
60
  <UBadge
@@ -59,10 +66,10 @@ function image(post: PostModel): string | undefined {
59
66
  class="shrink-0"
60
67
  />
61
68
  <h3
62
- v-if="post.title?.trim()"
69
+ v-if="titleOf(post)"
63
70
  class="min-w-0 line-clamp-2 text-sm font-medium leading-snug text-white drop-shadow-sm"
64
71
  >
65
- {{ post.title }}
72
+ {{ titleOf(post) }}
66
73
  </h3>
67
74
  <span
68
75
  v-if="(post.commentCount ?? 0) > 0"
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { resolveComponent } from 'vue'
3
3
  import type { PostModel } from 'memi-board/runtime'
4
- import { useMemiBoardI18n, videoListCoverUrl } from 'memi-board/runtime'
4
+ import { displayPostSummary, displayPostTitle, useMemiBoardI18n, videoListCoverUrl } from 'memi-board/runtime'
5
5
  import MemiBoardAuthorMenu from './AuthorMenu.vue'
6
6
 
7
7
  defineProps<{
@@ -14,7 +14,16 @@ defineProps<{
14
14
  }>()
15
15
  const emit = defineEmits<{ select: [post: PostModel] }>()
16
16
  const NuxtLink = resolveComponent('NuxtLink')
17
- const { t, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n()
17
+ const { t, locale, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n()
18
+
19
+ function titleOf(post: PostModel) {
20
+ return displayPostTitle(post, locale.value).trim()
21
+ }
22
+ function summaryOf(post: PostModel) {
23
+ const summary = displayPostSummary(post, locale.value).trim()
24
+ if (!summary || summary === titleOf(post)) return ''
25
+ return summary
26
+ }
18
27
 
19
28
  /** 영상 목록: YouTube URL 이 있으면 썸네일 우선, 그다음 본문/첨부 이미지. */
20
29
  function cover(post: PostModel): string | undefined {
@@ -37,7 +46,7 @@ function cover(post: PostModel): string | undefined {
37
46
  @click="!postTo(post) && emit('select', post)"
38
47
  >
39
48
  <div class="relative aspect-video overflow-hidden bg-elevated">
40
- <img v-if="cover(post)" :src="cover(post)" :alt="post.title" class="size-full object-cover">
49
+ <img v-if="cover(post)" :src="cover(post)" :alt="titleOf(post)" class="size-full object-cover">
41
50
  <div v-else class="flex size-full items-center justify-center text-muted"><UIcon name="i-lucide-video" class="size-10" /></div>
42
51
  <span class="absolute inset-0 flex items-center justify-center bg-black/10">
43
52
  <span class="flex size-12 items-center justify-center rounded-full bg-black/70 text-white shadow"><UIcon name="i-lucide-play" class="ml-0.5 size-6 fill-current" /></span>
@@ -53,7 +62,7 @@ function cover(post: PostModel): string | undefined {
53
62
  size="sm"
54
63
  class="mt-0.5 shrink-0"
55
64
  />
56
- <h3 class="min-w-0 line-clamp-2 font-medium text-inherit">{{ post.title }}</h3>
65
+ <h3 class="min-w-0 line-clamp-2 font-medium text-inherit">{{ titleOf(post) }}</h3>
57
66
  <span
58
67
  v-if="(post.commentCount ?? 0) > 0"
59
68
  class="mt-0.5 inline-flex shrink-0 items-center rounded-full bg-primary/10 px-1.5 py-px text-[11px] font-semibold tabular-nums text-primary"
@@ -61,7 +70,7 @@ function cover(post: PostModel): string | undefined {
61
70
  {{ post.commentCount }}
62
71
  </span>
63
72
  </div>
64
- <p v-if="post.summary" class="mt-2 line-clamp-2 whitespace-pre-line text-sm text-muted">{{ post.summary }}</p>
73
+ <p v-if="summaryOf(post)" class="mt-2 line-clamp-2 whitespace-pre-line text-sm text-muted">{{ summaryOf(post) }}</p>
65
74
  </div>
66
75
  </component>
67
76
 
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
3
  import type { BoardSection, CommentModel, PostModel } from 'memi-board/runtime'
4
- import { miniMoreLink, sectionColClass, sectionMinHeight, useMemiBoardI18n } from 'memi-board/runtime'
4
+ import { displayStoredField, miniMoreLink, sectionColClass, sectionMinHeight, useMemiBoardI18n } from 'memi-board/runtime'
5
5
  import MemiBoardMiniComments from './MiniComments.vue'
6
6
  import MemiBoardMiniList from './MiniList.vue'
7
7
  import MemiBoardMiniPost from './MiniPost.vue'
@@ -13,7 +13,14 @@ const props = defineProps<{
13
13
  getMoreLink?: (section: BoardSection) => string | undefined
14
14
  }>()
15
15
 
16
- const { t } = useMemiBoardI18n()
16
+ const { t, locale } = useMemiBoardI18n()
17
+ const title = computed(() => displayStoredField(
18
+ props.section.i18n,
19
+ props.section.i18nSource,
20
+ 'title',
21
+ props.section.title,
22
+ locale.value,
23
+ ))
17
24
  const moreTo = computed(() => props.getMoreLink?.(props.section) ?? miniMoreLink(props.section))
18
25
  </script>
19
26
 
@@ -30,7 +37,7 @@ const moreTo = computed(() => props.getMoreLink?.(props.section) ?? miniMoreLink
30
37
  v-if="section.showTitle"
31
38
  class="min-w-0 truncate text-sm font-semibold text-highlighted"
32
39
  >
33
- {{ section.title }}
40
+ {{ title }}
34
41
  </h2>
35
42
  <span v-else class="min-w-0" />
36
43
  <UButton
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onMounted, ref, watch } from 'vue'
3
- import { MEMI_BOARD_LOCALES, slugify, useMemiBoardAuth, useMemiBoardI18n, useMemiBoardSettings, useMemiBoardUsers } from 'memi-board/runtime'
3
+ import { boardCopyIsTranslated, MEMI_BOARD_LOCALES, slugify, useMemiBoardAuth, useMemiBoardI18n, useMemiBoardSettings, useMemiBoardUsers } from 'memi-board/runtime'
4
4
  import type { BoardCategory, BoardEditorType, BoardListView, BoardVisibility, BoardWriteRole } from 'memi-board/runtime'
5
5
  import MemiBoardOptionCards from './OptionCards.vue'
6
6
 
@@ -58,7 +58,7 @@ function fromLocaleOption(value: string) {
58
58
  }
59
59
 
60
60
  const { isAdmin, rolePending } = useMemiBoardAuth()
61
- const { categories, settingsPending, saveCategory, saveCategories, deleteCategory } = useMemiBoardSettings()
61
+ const { categories, settingsPending, saveCategory, saveCategories, deleteCategory, translateBoard } = useMemiBoardSettings()
62
62
  const canManage = computed(() => isAdmin.value || props.authorized)
63
63
  // users 전체 목록은 rules상 board-role 관리자만 읽을 수 있다 — "허용 스태프" 지정 권한이
64
64
  // 있을 때만(기본은 board-role 관리자) 쿼리를 시작한다. canManage(호스트 authorized 포함)로
@@ -82,6 +82,8 @@ const pending = computed(() => !mounted.value || rolePending.value || settingsPe
82
82
  const draft = ref<BoardCategory[]>([])
83
83
  const newLabel = ref('')
84
84
  const savingId = ref<string | null>(null)
85
+ const translatingId = ref<string | null>(null)
86
+ const translatedDraftIds = ref(new Set<string>())
85
87
  const savedId = ref<string | null>(null)
86
88
  const deletingId = ref<string | null>(null)
87
89
  const ordering = ref(false)
@@ -104,6 +106,7 @@ watch([categories, settingsPending], ([list, loading]) => {
104
106
  allowedStaffUids: category.allowedStaffUids ?? [],
105
107
  allowedManagerUids: category.allowedManagerUids ?? [],
106
108
  locale: category.locale ?? '',
109
+ autoTranslate: category.autoTranslate === true,
107
110
  }))
108
111
  }
109
112
  }, { immediate: true })
@@ -174,16 +177,55 @@ function categoryTo(id: string) {
174
177
  return `${props.categoryBase.replace(/\/$/, '')}/${encodeURIComponent(id)}`
175
178
  }
176
179
 
180
+ async function translateCopy(category: BoardCategory) {
181
+ if (!category.label.trim() || translatingId.value || savingId.value) return
182
+ const snapshot = { ...category }
183
+ translatingId.value = category.id
184
+ savedId.value = null
185
+ error.value = ''
186
+ try {
187
+ const translated = await translateBoard(snapshot)
188
+ if (!draft.value.includes(category) || category.label !== snapshot.label || category.description !== snapshot.description) return
189
+ Object.assign(category, { label: translated.label, description: translated.description, i18n: translated.i18n, i18nSource: translated.i18nSource })
190
+ translatedDraftIds.value.add(category.id)
191
+ }
192
+ catch (cause) {
193
+ console.warn('[memi-board] board translation failed', cause)
194
+ error.value = t('settings.autoTranslate.manualFailed')
195
+ }
196
+ finally {
197
+ translatingId.value = null
198
+ }
199
+ }
200
+
177
201
  async function save(category: BoardCategory, index: number) {
202
+ if (translatingId.value || savingId.value) return
178
203
  error.value = ''
179
204
  savedId.value = null
180
205
  savingId.value = category.id
206
+ category.autoTranslate = category.autoTranslate === true
207
+ let translateFailed = false
208
+ if (category.autoTranslate) {
209
+ try {
210
+ const translated = await translateBoard(category)
211
+ category.i18n = translated.i18n
212
+ category.i18nSource = translated.i18nSource
213
+ category.label = translated.label
214
+ category.description = translated.description
215
+ }
216
+ catch (cause) {
217
+ console.warn('[memi-board] board translation failed', cause)
218
+ translateFailed = true
219
+ }
220
+ }
181
221
  try {
182
222
  const order = props.categoryId ? (category.order ?? index) : index
183
223
  await saveCategory(category, order)
184
224
  category.order = order
185
225
  savedId.value = category.id
226
+ translatedDraftIds.value.delete(category.id)
186
227
  emit('saved', draft.value)
228
+ if (translateFailed) error.value = t('settings.autoTranslate.failed')
187
229
  }
188
230
  catch (cause) {
189
231
  error.value = cause instanceof Error ? cause.message : t('settings.error.saveFailed')
@@ -298,18 +340,41 @@ async function runDeleteAll() {
298
340
  </div>
299
341
  <div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
300
342
  <div><p class="text-sm font-medium">{{ t('settings.label.label') }}</p><p class="text-xs text-muted">{{ t('settings.label.help') }}</p></div>
301
- <UInput v-model="category.label" @update:model-value="savedId = null" />
343
+ <div class="flex flex-wrap items-center gap-2">
344
+ <UInput v-model="category.label" class="min-w-0 flex-1" :disabled="translatingId === category.id" @update:model-value="savedId = null" />
345
+ <UButton
346
+ :label="t('settings.autoTranslate.action')"
347
+ icon="i-lucide-languages"
348
+ color="neutral"
349
+ variant="outline"
350
+ :loading="translatingId === category.id"
351
+ :disabled="savingId !== null || translatingId !== null || !category.label.trim()"
352
+ @click="translateCopy(category)"
353
+ />
354
+ </div>
302
355
  </div>
303
356
  <div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-start">
304
357
  <div><p class="text-sm font-medium">{{ t('settings.description.label') }}</p><p class="text-xs text-muted">{{ t('settings.description.help') }}</p></div>
305
- <UTextarea
306
- v-model="category.description"
307
- :rows="3"
308
- autoresize
309
- :maxrows="6"
310
- :placeholder="t('settings.description.placeholder')"
311
- @update:model-value="savedId = null"
312
- />
358
+ <div class="flex flex-col gap-2">
359
+ <UTextarea
360
+ v-model="category.description"
361
+ :disabled="translatingId === category.id"
362
+ :rows="3"
363
+ autoresize
364
+ :maxrows="6"
365
+ :placeholder="t('settings.description.placeholder')"
366
+ @update:model-value="savedId = null"
367
+ />
368
+ <div class="flex items-center justify-between gap-3">
369
+ <div>
370
+ <p class="text-sm font-medium">{{ t('settings.autoTranslate.label') }}</p>
371
+ <p class="text-xs text-muted">{{ t('settings.autoTranslate.help') }}</p>
372
+ </div>
373
+ <USwitch v-model="category.autoTranslate" size="sm" @update:model-value="savedId = null" />
374
+ </div>
375
+ <p class="text-xs text-muted">{{ t('settings.autoTranslate.manualHelp') }}</p>
376
+ <p v-if="boardCopyIsTranslated(category)" class="text-xs text-primary">{{ t(translatedDraftIds.has(category.id) ? 'settings.autoTranslate.pending' : 'settings.autoTranslate.ready') }}</p>
377
+ </div>
313
378
  </div>
314
379
  <div v-if="canManageStaffAssignment" class="flex flex-col gap-2">
315
380
  <div>
@@ -378,6 +443,7 @@ async function runDeleteAll() {
378
443
  icon="i-lucide-save"
379
444
  size="sm"
380
445
  :loading="savingId === category.id"
446
+ :disabled="translatingId !== null || savingId !== null"
381
447
  @click="save(category, index)"
382
448
  />
383
449
  </div>
@@ -90,9 +90,9 @@ export async function useMemiBoardPostSeo(opts?: {
90
90
 
91
91
  // await 하지 않고 먼저 등록 → data ref 확보 후 메타 연결
92
92
  const postAsync = useAsyncData(
93
- () => `memi-board-seo-post-${category.value}-${slug.value}`,
94
- () => fetchPublicPostForSeo(category.value, slug.value, seoDb),
95
- { watch: [category, slug], server: true, lazy: false },
93
+ () => `memi-board-seo-post-${category.value}-${slug.value}-${locale.value}`,
94
+ () => fetchPublicPostForSeo(category.value, slug.value, seoDb, locale.value),
95
+ { watch: [category, slug, locale], server: true, lazy: false },
96
96
  )
97
97
  const post = computed(() => postAsync.data.value ?? null)
98
98
 
@@ -443,6 +443,10 @@ var L = {
443
443
  summarize: "짧게 요약하기",
444
444
  translateEn: "영어로 번역",
445
445
  translateKo: "한국어로 번역",
446
+ translateAll: "10개 언어로 번역해 저장",
447
+ translateSaved: "10개 언어로 저장했습니다. 다른 언어로 보면 번역이 보입니다.",
448
+ translatePending: "10개 언어 번역을 준비했습니다. 글을 저장하면 함께 기록됩니다.",
449
+ translateEmpty: "번역할 제목이나 본문을 먼저 입력해 주세요.",
446
450
  title: "본문에서 제목 만들기",
447
451
  custom: "커스텀 스타일로 바꾸기…",
448
452
  failed: "AI 작업에 실패했습니다. 다시 시도해 주세요."
@@ -739,6 +743,16 @@ var L = {
739
743
  help: "비우면 사이트 언어를 따릅니다. 고르면 이 게시판은 항상 그 언어로 보입니다.",
740
744
  auto: "자동 (사이트 언어 따름)"
741
745
  },
746
+ autoTranslate: {
747
+ action: "10개 언어로 번역",
748
+ manualHelp: "표시 라벨과 설명을 함께 번역합니다. 번역 후 저장 버튼을 눌러 적용하세요.",
749
+ pending: "10개 언어 번역이 준비되었습니다. 저장 버튼을 눌러 적용하세요.",
750
+ manualFailed: "번역하지 못했습니다. 입력한 내용은 유지됩니다. 다시 시도해 주세요.",
751
+ label: "저장할 때 10개 언어로 번역",
752
+ help: "켜 두면 이름과 설명을 10개 언어로 번역합니다. 꺼져 있으면 번역하지 않습니다. 문구가 그대로면 다시 번역하지 않습니다.",
753
+ failed: "설정은 저장했지만 10개 언어 번역에 실패했습니다. 다시 저장해 주세요.",
754
+ ready: "이름과 설명이 10개 언어로 저장되어 있습니다."
755
+ },
742
756
  action: {
743
757
  moveDown: "아래로",
744
758
  goToBoard: "게시판으로 이동",
@@ -819,6 +833,9 @@ var L = {
819
833
  contentRequired: "AI로 다듬을 내용을 먼저 입력해 주세요.",
820
834
  customRequired: "어떻게 바꿀지 지시문을 입력해 주세요.",
821
835
  emptyResult: "AI가 결과를 만들지 못했습니다. 다시 시도해 주세요.",
836
+ translateEmpty: "번역할 제목이나 본문을 먼저 입력해 주세요.",
837
+ translateTooLong: "본문이 {limit}자를 넘으면 10개 언어로 저장할 수 없습니다. 줄인 뒤 다시 시도해 주세요.",
838
+ translateIncomplete: "일부 언어 번역이 비었습니다. 다시 시도해 주세요.",
822
839
  failed: "AI 작업에 실패했습니다. 다시 시도해 주세요.",
823
840
  actions: {
824
841
  proofread: "맞춤법 검사",
@@ -827,20 +844,21 @@ var L = {
827
844
  summarize: "짧게 요약하기",
828
845
  translateEn: "영어로 번역",
829
846
  translateKo: "한국어로 번역",
847
+ translateAll: "10개 언어로 번역해 저장",
830
848
  title: "본문에서 제목 만들기",
831
849
  custom: "커스텀 스타일로 바꾸기…"
832
850
  }
833
851
  }
834
852
  }, R = {
835
- en: () => import("./en-CaPfPNDy.js"),
836
- ja: () => import("./ja-oTWri4hY.js"),
837
- de: () => import("./de-ddWQNaqs.js"),
838
- fr: () => import("./fr-C3NCMNoy.js"),
839
- es: () => import("./es-7JHjGVlM.js"),
840
- pt: () => import("./pt-B9EH4scA.js"),
841
- zh: () => import("./zh-D5rDYV6E.js"),
842
- ar: () => import("./ar-BCQdRw_S.js"),
843
- id: () => import("./id-C8hCAMPJ.js")
853
+ en: () => import("./en-Broh-9df.js"),
854
+ ja: () => import("./ja-D2dIWUse.js"),
855
+ de: () => import("./de-DQqSsBsZ.js"),
856
+ fr: () => import("./fr-CbXPRKeU.js"),
857
+ es: () => import("./es-D3eEYN6w.js"),
858
+ pt: () => import("./pt-BAuHdglU.js"),
859
+ zh: () => import("./zh-CMyzTdQ5.js"),
860
+ ar: () => import("./ar-D_6hknjh.js"),
861
+ id: () => import("./id-DxVqvPpo.js")
844
862
  }, z = "__MEMI_BOARD_I18N__";
845
863
  function B() {
846
864
  let e = globalThis;
@@ -948,4 +966,4 @@ function _e(e, t = 60) {
948
966
  //#endregion
949
967
  export { ae as A, ie as B, h as C, ue as D, ce as E, C as F, te as G, re as H, b as I, y as L, x as M, T as N, oe as O, w as P, le as R, f as S, _ as T, v as U, E as V, ee as W, de as _, K as a, p as b, X as c, W as d, M as f, F as g, I as h, ge as i, S as j, se as k, fe as l, j as m, $ as n, Y as o, N as p, Z as r, J as s, _e as t, H as u, A as v, g as w, m as x, k as y, ne as z };
950
968
 
951
- //# sourceMappingURL=slugify-DJ82ifCd.js.map
969
+ //# sourceMappingURL=slugify-B05Qykf_.js.map