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
@@ -1,9 +1,11 @@
1
1
  <script setup lang="ts">
2
+ import { imeSafeSubmitClick, imeSafeSubmitPointerDown } from 'memi-board/runtime'
2
3
  import { computed, ref } from 'vue'
3
4
  import { canWriteCommentByRole, useMemiBoardAuth } from 'memi-board/runtime'
4
5
  import { COMMENT_BODY_MAX_LENGTH, useMemiBoardComments } from 'memi-board/runtime'
5
6
  import { useMemiBoardModeration } from 'memi-board/runtime'
6
7
  import { useMemiBoardSettings } from 'memi-board/runtime'
8
+ import { useMemiBoardI18n } from 'memi-board/runtime'
7
9
  import type { CommentModel } from 'memi-board/runtime'
8
10
 
9
11
  const props = defineProps<{ boardId: string, postId: string, parent?: CommentModel | null }>()
@@ -17,6 +19,7 @@ const { addComment, addReply } = useMemiBoardComments(
17
19
  { subscribe: false },
18
20
  )
19
21
  const { checkText } = useMemiBoardModeration()
22
+ const { t, formatNumber } = useMemiBoardI18n()
20
23
  const { getBoard } = useMemiBoardSettings()
21
24
 
22
25
  const board = computed(() => getBoard(props.boardId))
@@ -26,8 +29,8 @@ const canComment = computed(() => {
26
29
  return canWriteCommentByRole(user.value.uid, isAdmin.value, isStaff.value, board.value)
27
30
  })
28
31
  const deniedMessage = computed(() => {
29
- if (isHiddenBoard.value) return '숨김 게시판에는 담당 스태프만 댓글을 남길 수 있습니다.'
30
- return '이 게시판에는 댓글쓰기 권한이 없습니다.'
32
+ if (isHiddenBoard.value) return t('commentForm.hiddenBoardDenied')
33
+ return t('commentForm.denied')
31
34
  })
32
35
 
33
36
  const body = ref('')
@@ -35,10 +38,11 @@ const submitting = ref(false)
35
38
  const error = ref('')
36
39
 
37
40
  async function handleSubmit() {
41
+ if (submitting.value) return
38
42
  error.value = ''
39
43
  if (!body.value.trim()) return
40
44
  if (!user.value) {
41
- error.value = '로그인이 필요합니다.'
45
+ error.value = t('commentForm.signInRequired')
42
46
  return
43
47
  }
44
48
  if (!canComment.value) {
@@ -47,7 +51,7 @@ async function handleSubmit() {
47
51
  }
48
52
  if (isWriteRestricted.value) {
49
53
  error.value = restrictedMessage.value
50
- || '콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.'
54
+ || t('commentForm.writeRestricted')
51
55
  return
52
56
  }
53
57
 
@@ -55,7 +59,7 @@ async function handleSubmit() {
55
59
  try {
56
60
  const moderation = await checkText(body.value)
57
61
  if (moderation.flagged) {
58
- error.value = moderation.reason || '작성할 수 없는 내용이 포함되어 있습니다.'
62
+ error.value = moderation.reason || t('commentForm.moderationBlocked')
59
63
  return
60
64
  }
61
65
  const input = {
@@ -86,7 +90,7 @@ async function handleSubmit() {
86
90
  >
87
91
  <UTextarea
88
92
  v-model="body"
89
- :placeholder="parent ? `${parent.authorName || '사용자'}님에게 답글` : '댓글을 입력하세요'"
93
+ :placeholder="parent ? t('commentForm.replyTo', { name: parent.authorName || t('common.label.user') }) : t('commentForm.placeholder')"
90
94
  :rows="2"
91
95
  :maxlength="COMMENT_BODY_MAX_LENGTH"
92
96
  />
@@ -97,7 +101,7 @@ async function handleSubmit() {
97
101
  {{ restrictedMessage }}
98
102
  </p>
99
103
  <div class="flex justify-between items-center">
100
- <span class="text-xs text-dimmed">{{ body.length.toLocaleString() }} / {{ COMMENT_BODY_MAX_LENGTH.toLocaleString() }}</span>
104
+ <span class="text-xs text-dimmed">{{ formatNumber(body.length) }} / {{ formatNumber(COMMENT_BODY_MAX_LENGTH) }}</span>
101
105
  <p
102
106
  v-if="error"
103
107
  class="text-xs text-error"
@@ -109,15 +113,17 @@ async function handleSubmit() {
109
113
  v-if="parent"
110
114
  type="button"
111
115
  size="sm"
112
- label="취소"
116
+ :label="t('common.action.cancel')"
113
117
  color="neutral"
114
118
  variant="ghost"
115
119
  @click="emit('cancel')"
116
120
  />
117
121
  <UButton
118
122
  type="submit"
123
+ @pointerdown="imeSafeSubmitPointerDown"
124
+ @click="imeSafeSubmitClick"
119
125
  size="sm"
120
- :label="parent ? '답글 작성' : '댓글 작성'"
126
+ :label="parent ? t('commentForm.submitReply') : t('commentForm.submit')"
121
127
  :loading="submitting"
122
128
  :disabled="!body.trim() || isWriteRestricted"
123
129
  />
@@ -127,7 +133,7 @@ async function handleSubmit() {
127
133
  v-else-if="!isSignedIn"
128
134
  class="text-sm text-muted"
129
135
  >
130
- 댓글을 작성하려면 로그인이 필요합니다.
136
+ {{ t('commentForm.signInToComment') }}
131
137
  </p>
132
138
  <p
133
139
  v-else
@@ -1,22 +1,17 @@
1
1
  <script setup lang="ts">
2
+ import { imeSafeSubmitClick, imeSafeSubmitPointerDown } from 'memi-board/runtime'
2
3
  import { computed, inject, ref, watch } from 'vue'
3
4
  import { Timestamp } from 'firebase/firestore'
4
- import dayjs from 'dayjs'
5
- import relativeTime from 'dayjs/plugin/relativeTime'
6
- import 'dayjs/locale/ko'
7
5
  import type { CommentModel } from 'memi-board/runtime'
8
6
  import {
9
7
  COMMENT_BODY_MAX_LENGTH,
10
- formatTimestampDetails,
11
8
  memiBoardCommentLikesKey,
12
9
  useMemiBoardAuth,
13
10
  useMemiBoardComments,
11
+ useMemiBoardI18n,
14
12
  useMemiBoardModeration,
15
13
  } from 'memi-board/runtime'
16
14
 
17
- dayjs.extend(relativeTime)
18
- dayjs.locale('ko')
19
-
20
15
  const props = defineProps<{
21
16
  comment: CommentModel
22
17
  boardId: string
@@ -37,6 +32,7 @@ const { updateComment, setCommentBlinded } = useMemiBoardComments(
37
32
  { subscribe: false },
38
33
  )
39
34
  const { checkText } = useMemiBoardModeration()
35
+ const { t, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n()
40
36
  const commentLikes = inject(memiBoardCommentLikesKey, null)
41
37
 
42
38
  const editing = ref(false)
@@ -63,7 +59,7 @@ watch(() => props.comment.likeCount, (likeCount) => {
63
59
  })
64
60
 
65
61
  const date = computed(() => props.comment.createdAt?.toDate?.())
66
- const relativeDate = computed(() => date.value ? dayjs(date.value).from(dayjs(props.now)) : '방금 전')
62
+ const relativeDate = computed(() => formatRelativeDate(props.comment.createdAt, props.now))
67
63
  const timestampDetails = computed(() => formatTimestampDetails(props.comment.createdAt, localUpdatedAt.value).join('\n'))
68
64
  const blindLines = computed(() => Math.min(3, Math.max(1, Math.ceil(localBody.value.length / 45))))
69
65
 
@@ -80,13 +76,14 @@ function cancelEdit() {
80
76
  }
81
77
 
82
78
  async function saveEdit() {
79
+ if (saving.value) return
83
80
  const body = editBody.value.trim()
84
81
  if (!props.comment.id || !body || body === localBody.value) {
85
82
  if (body === localBody.value) editing.value = false
86
83
  return
87
84
  }
88
85
  if (isWriteRestricted.value) {
89
- editError.value = restrictedMessage.value || '댓글 수정이 잠시 제한됐어요.'
86
+ editError.value = restrictedMessage.value || t('commentItem.editRestricted')
90
87
  return
91
88
  }
92
89
 
@@ -95,7 +92,7 @@ async function saveEdit() {
95
92
  try {
96
93
  const moderation = await checkText(body)
97
94
  if (moderation.flagged) {
98
- editError.value = moderation.reason || '작성할 수 없는 내용이 포함되어 있습니다.'
95
+ editError.value = moderation.reason || t('commentItem.moderationBlocked')
99
96
  return
100
97
  }
101
98
  await updateComment(props.comment.id, body)
@@ -104,7 +101,7 @@ async function saveEdit() {
104
101
  editing.value = false
105
102
  }
106
103
  catch (cause) {
107
- editError.value = cause instanceof Error ? cause.message : '댓글을 수정하지 못했습니다.'
104
+ editError.value = cause instanceof Error ? cause.message : t('commentItem.editFailed')
108
105
  }
109
106
  finally {
110
107
  saving.value = false
@@ -115,8 +112,8 @@ async function toggleBlind() {
115
112
  if (!props.comment.id || !user.value || !canManageBoard(props.comment.boardId)) return
116
113
  const next = !localBlinded.value
117
114
  const confirmed = window.confirm(next
118
- ? '이 댓글을 블라인드하면 모든 사용자에게 본문 대신 블라인드 안내가 표시됩니다. 원문은 삭제되지 않으며 나중에 다시 해제할 수 있습니다.\n\n블라인드하시겠습니까?'
119
- : '블라인드를 해제하면 댓글 원문이 모든 사용자에게 다시 표시됩니다.\n\n블라인드를 해제하시겠습니까?')
115
+ ? t('commentItem.blindConfirm')
116
+ : t('commentItem.unblindConfirm'))
120
117
  if (!confirmed) return
121
118
 
122
119
  blindSaving.value = true
@@ -127,7 +124,7 @@ async function toggleBlind() {
127
124
  if (next) cancelEdit()
128
125
  }
129
126
  catch (cause) {
130
- editError.value = cause instanceof Error ? cause.message : '블라인드 상태를 변경하지 못했습니다.'
127
+ editError.value = cause instanceof Error ? cause.message : t('commentItem.blindFailed')
131
128
  }
132
129
  finally {
133
130
  blindSaving.value = false
@@ -143,7 +140,7 @@ async function toggleLike() {
143
140
  if (next !== wasLiked) localLikeCount.value = Math.max(0, localLikeCount.value + (next ? 1 : -1))
144
141
  }
145
142
  catch (cause) {
146
- likeError.value = cause instanceof Error ? cause.message : '좋아요를 처리하지 못했습니다.'
143
+ likeError.value = cause instanceof Error ? cause.message : t('commentItem.likeFailed')
147
144
  }
148
145
  }
149
146
  </script>
@@ -152,12 +149,12 @@ async function toggleLike() {
152
149
  <div class="flex items-start gap-3">
153
150
  <UAvatar
154
151
  :src="comment.authorPhoto ?? undefined"
155
- :alt="comment.authorName ?? '익명'"
152
+ :alt="comment.authorName ?? t('common.label.anonymous')"
156
153
  size="sm"
157
154
  />
158
155
  <div class="min-w-0 flex-1">
159
156
  <div class="flex items-center gap-2">
160
- <span class="text-sm font-medium">{{ comment.authorName ?? '익명' }}</span>
157
+ <span class="text-sm font-medium">{{ comment.authorName ?? t('common.label.anonymous') }}</span>
161
158
  <UPopover :content="{ side: 'top' }" :ui="{ content: 'h-auto w-max' }">
162
159
  <time
163
160
  :datetime="date?.toISOString()"
@@ -192,7 +189,7 @@ async function toggleLike() {
192
189
  <div class="flex justify-end gap-1">
193
190
  <UButton
194
191
  type="button"
195
- label="취소"
192
+ :label="t('common.action.cancel')"
196
193
  size="xs"
197
194
  color="neutral"
198
195
  variant="ghost"
@@ -201,7 +198,9 @@ async function toggleLike() {
201
198
  />
202
199
  <UButton
203
200
  type="submit"
204
- label="저장"
201
+ @pointerdown="imeSafeSubmitPointerDown"
202
+ @click="imeSafeSubmitClick"
203
+ :label="t('common.action.save')"
205
204
  size="xs"
206
205
  :loading="saving"
207
206
  :disabled="!editBody.trim() || editBody.trim() === localBody"
@@ -221,7 +220,7 @@ async function toggleLike() {
221
220
  />
222
221
  </div>
223
222
  <div class="absolute inset-0 flex items-center justify-center px-3 text-center text-xs font-medium text-muted">
224
- 관리자에 의해 블라인드된 댓글입니다.
223
+ {{ t('commentItem.blinded') }}
225
224
  </div>
226
225
  </div>
227
226
  <p v-else class="whitespace-pre-wrap break-words text-sm">
@@ -241,13 +240,13 @@ async function toggleLike() {
241
240
  :color="isLiked ? 'error' : 'neutral'"
242
241
  :label="String(localLikeCount)"
243
242
  :loading="likePending"
244
- :aria-label="isLiked ? '좋아요 취소' : '좋아요'"
243
+ :aria-label="isLiked ? t('common.action.unlike') : t('common.action.like')"
245
244
  @click="toggleLike"
246
245
  />
247
246
  <span
248
247
  v-else
249
248
  class="flex items-center gap-0.5 px-1 text-xs text-muted"
250
- aria-label="좋아요"
249
+ :aria-label="t('common.action.like')"
251
250
  >
252
251
  <UIcon name="i-lucide-heart" class="size-3.5" />
253
252
  {{ localLikeCount }}
@@ -258,7 +257,7 @@ async function toggleLike() {
258
257
  size="xs"
259
258
  color="neutral"
260
259
  variant="ghost"
261
- aria-label="댓글 수정"
260
+ :aria-label="t('commentItem.edit')"
262
261
  :disabled="editing"
263
262
  @click="startEdit"
264
263
  />
@@ -269,7 +268,7 @@ async function toggleLike() {
269
268
  :color="localBlinded ? 'neutral' : 'warning'"
270
269
  variant="ghost"
271
270
  :loading="blindSaving"
272
- :aria-label="localBlinded ? '댓글 블라인드 해제' : '댓글 블라인드'"
271
+ :aria-label="localBlinded ? t('commentItem.unblind') : t('commentItem.blind')"
273
272
  @click="toggleBlind"
274
273
  />
275
274
  <UButton
@@ -277,7 +276,7 @@ async function toggleLike() {
277
276
  size="xs"
278
277
  color="neutral"
279
278
  variant="ghost"
280
- aria-label="답글"
279
+ :aria-label="t('common.action.reply')"
281
280
  @click="emit('reply', comment)"
282
281
  />
283
282
  <UButton
@@ -287,7 +286,7 @@ async function toggleLike() {
287
286
  variant="ghost"
288
287
  color="error"
289
288
  :loading="deleting"
290
- aria-label="댓글 삭제"
289
+ :aria-label="t('commentItem.delete')"
291
290
  @click="emit('delete', comment.id)"
292
291
  />
293
292
  </div>
@@ -4,12 +4,15 @@ import {
4
4
  memiBoardCommentLikesKey,
5
5
  useMemiBoardCommentLikes,
6
6
  useMemiBoardComments,
7
+ useMemiBoardI18n,
7
8
  } from 'memi-board/runtime'
8
9
  import MemiBoardCommentSkeleton from './CommentSkeleton.vue'
9
10
  import MemiBoardCommentThread from './CommentThread.vue'
10
11
 
11
12
  const props = defineProps<{ boardId: string, postId: string }>()
12
13
 
14
+ const { t } = useMemiBoardI18n()
15
+
13
16
  const { comments, commentsPending, hasMore, loadingMore, loadMore } = useMemiBoardComments(
14
17
  toRef(props, 'boardId'),
15
18
  toRef(props, 'postId'),
@@ -79,14 +82,14 @@ onUnmounted(() => {
79
82
 
80
83
  <template>
81
84
  <div class="flex flex-col gap-3">
82
- <div v-if="commentsPending" class="flex flex-col gap-3" aria-label="댓글을 불러오고 있습니다">
85
+ <div v-if="commentsPending" class="flex flex-col gap-3" :aria-label="t('commentList.loading')">
83
86
  <MemiBoardCommentSkeleton v-for="index in 10" :key="`initial-${index}`" />
84
87
  </div>
85
88
  <p
86
89
  v-else-if="!comments.length"
87
90
  class="text-sm text-muted"
88
91
  >
89
- 아직 댓글이 없습니다.
92
+ {{ t('commentList.empty') }}
90
93
  </p>
91
94
 
92
95
  <MemiBoardCommentThread
@@ -104,7 +107,7 @@ onUnmounted(() => {
104
107
  class="flex justify-center py-3"
105
108
  >
106
109
  <UButton
107
- label="댓글 더보기"
110
+ :label="t('commentList.loadMore')"
108
111
  icon="i-lucide-chevron-down"
109
112
  color="neutral"
110
113
  variant="soft"
@@ -114,7 +117,7 @@ onUnmounted(() => {
114
117
  />
115
118
  </div>
116
119
 
117
- <div v-if="morePending" class="flex flex-col gap-3" aria-label="댓글을 더 불러오고 있습니다">
120
+ <div v-if="morePending" class="flex flex-col gap-3" :aria-label="t('commentList.loadingMore')">
118
121
  <MemiBoardCommentSkeleton v-for="index in 10" :key="`more-${index}`" />
119
122
  </div>
120
123
  </div>
@@ -1,13 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onMounted, ref, watch } from 'vue'
3
3
  import type { CommentModel } from 'memi-board/runtime'
4
- import { useMemiBoardComments, useMemiBoardReplies } from 'memi-board/runtime'
4
+ import { useMemiBoardComments, useMemiBoardI18n, useMemiBoardReplies } from 'memi-board/runtime'
5
5
  import MemiBoardCommentForm from './CommentForm.vue'
6
6
  import MemiBoardCommentItem from './CommentItem.vue'
7
7
  import MemiBoardCommentSkeleton from './CommentSkeleton.vue'
8
8
 
9
9
  const props = defineProps<{ boardId: string, postId: string, root: CommentModel, now: number }>()
10
10
 
11
+ const { t } = useMemiBoardI18n()
12
+
11
13
  const { deleteComment } = useMemiBoardComments(
12
14
  props.boardId,
13
15
  props.postId,
@@ -56,7 +58,7 @@ async function handleSaved() {
56
58
  }
57
59
 
58
60
  async function handleDelete(comment: CommentModel) {
59
- if (!window.confirm('이 댓글을 삭제하시겠습니까?')) return
61
+ if (!window.confirm(t('commentThread.deleteConfirm'))) return
60
62
  deletingId.value = comment.id ?? null
61
63
  error.value = ''
62
64
  try {
@@ -64,7 +66,7 @@ async function handleDelete(comment: CommentModel) {
64
66
  if (comment.parentId) await refresh()
65
67
  }
66
68
  catch (cause) {
67
- error.value = cause instanceof Error ? cause.message : '댓글을 삭제하지 못했습니다.'
69
+ error.value = cause instanceof Error ? cause.message : t('commentThread.deleteFailed')
68
70
  }
69
71
  finally {
70
72
  deletingId.value = null
@@ -86,7 +88,7 @@ async function handleDelete(comment: CommentModel) {
86
88
 
87
89
  <UButton
88
90
  v-if="(root.replyCount ?? 0) > 0"
89
- :label="expanded ? '답글 숨기기' : `답글 ${root.replyCount}개`"
91
+ :label="expanded ? t('commentThread.hideReplies') : t('commentThread.showReplies', { count: root.replyCount ?? 0 })"
90
92
  :icon="expanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
91
93
  color="neutral"
92
94
  variant="link"
@@ -111,7 +113,7 @@ async function handleDelete(comment: CommentModel) {
111
113
  />
112
114
  <UButton
113
115
  v-if="needsMoreReplies"
114
- label="답글 더보기"
116
+ :label="t('commentThread.loadMoreReplies')"
115
117
  color="neutral"
116
118
  variant="soft"
117
119
  size="sm"
@@ -2,7 +2,7 @@
2
2
  import { ref, computed, onBeforeUnmount, onMounted, watch } from 'vue'
3
3
  import Youtube from '@tiptap/extension-youtube'
4
4
  import type { PostModel } from 'memi-board/runtime'
5
- import { formatRelativeDate, formatTimestampDetails, renderMarkdownToHtml } from 'memi-board/runtime'
5
+ import { renderMarkdownToHtml, useMemiBoardI18n } from 'memi-board/runtime'
6
6
  import { useMemiBoardPost, useMemiBoardPosts } from 'memi-board/runtime'
7
7
  import { useMemiBoardAuth } from 'memi-board/runtime'
8
8
  import { useMemiBoardSettings } from 'memi-board/runtime'
@@ -36,7 +36,9 @@ const emit = defineEmits<{
36
36
 
37
37
  const { getAdjacentPosts, deletePost, publishPost } = useMemiBoardPosts(() => props.boardId)
38
38
  const { canEdit, canDelete } = useMemiBoardAuth()
39
- const { boardLabel } = useMemiBoardSettings()
39
+ const { boardLabel, getBoard } = useMemiBoardSettings()
40
+ // 보드 설정 언어 → 호스트 언어 순. 댓글 등 자식 컴포넌트도 이 언어를 따른다.
41
+ const { t, dir, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n({ boardLocale: () => getBoard(props.boardId)?.locale })
40
42
  const { recordView } = useMemiBoardViews()
41
43
 
42
44
  // 실시간 구독 — 다른 사람의 좋아요·댓글 수 변경이 화면에 바로 반영된다.
@@ -124,7 +126,7 @@ async function handlePublish() {
124
126
 
125
127
  async function handleDelete() {
126
128
  if (!post.value) return
127
- if (!window.confirm('이 게시글을 삭제하시겠습니까? 되돌릴 수 없습니다.')) return
129
+ if (!window.confirm(t('detail.deleteConfirm'))) return
128
130
  deleting.value = true
129
131
  try {
130
132
  await deletePost(props.postId)
@@ -152,6 +154,7 @@ const contentHtml = computed(() => {
152
154
  <template>
153
155
  <div
154
156
  v-if="loading"
157
+ :dir="dir"
155
158
  class="flex flex-col gap-3"
156
159
  >
157
160
  <USkeleton class="h-8 w-2/3" />
@@ -160,13 +163,15 @@ const contentHtml = computed(() => {
160
163
 
161
164
  <p
162
165
  v-else-if="notFound"
166
+ :dir="dir"
163
167
  class="text-sm text-muted"
164
168
  >
165
- 게시글을 찾을 수 없습니다.
169
+ {{ t('detail.notFound') }}
166
170
  </p>
167
171
 
168
172
  <div
169
173
  v-else-if="post"
174
+ :dir="dir"
170
175
  class="flex flex-col gap-6 touch-pan-y"
171
176
  @touchstart.passive="onTouchStart"
172
177
  @touchend.passive="onTouchEnd"
@@ -175,13 +180,13 @@ const contentHtml = computed(() => {
175
180
  <div class="flex items-start gap-2">
176
181
  <UIcon name="i-lucide-eye-off" class="mt-0.5 size-4 shrink-0 text-warning" />
177
182
  <div>
178
- <p class="text-sm font-medium text-highlighted">미공개 미리보기</p>
179
- <p class="text-xs text-muted">아직 게시되지 않았습니다. 게시하기 전까지는 작성자만 볼 수 있어요.</p>
183
+ <p class="text-sm font-medium text-highlighted">{{ t('detail.draftPreview') }}</p>
184
+ <p class="text-xs text-muted">{{ t('detail.draftNotice') }}</p>
180
185
  </div>
181
186
  </div>
182
187
  <UButton
183
188
  v-if="canEdit(post)"
184
- label="게시하기"
189
+ :label="t('detail.publish')"
185
190
  icon="i-lucide-send"
186
191
  size="sm"
187
192
  class="shrink-0"
@@ -207,7 +212,7 @@ const contentHtml = computed(() => {
207
212
  </div>
208
213
  <UButton
209
214
  icon="i-lucide-arrow-left"
210
- aria-label="목록으로 돌아가기"
215
+ :aria-label="t('detail.backToList')"
211
216
  color="neutral"
212
217
  variant="ghost"
213
218
  size="sm"
@@ -283,7 +288,7 @@ const contentHtml = computed(() => {
283
288
  size="sm"
284
289
  variant="ghost"
285
290
  color="neutral"
286
- label="수정"
291
+ :label="t('common.action.edit')"
287
292
  @click="emit('edit', postId)"
288
293
  />
289
294
  <UButton
@@ -292,7 +297,7 @@ const contentHtml = computed(() => {
292
297
  size="sm"
293
298
  variant="ghost"
294
299
  color="error"
295
- label="삭제"
300
+ :label="t('common.action.delete')"
296
301
  :loading="deleting"
297
302
  @click="handleDelete"
298
303
  />
@@ -315,12 +320,12 @@ const contentHtml = computed(() => {
315
320
  />
316
321
  </div>
317
322
 
318
- <nav class="grid grid-cols-3 items-center py-3" aria-label="게시글 이동">
323
+ <nav class="grid grid-cols-3 items-center py-3" :aria-label="t('detail.postNav')">
319
324
  <UButton
320
325
  variant="ghost"
321
326
  color="neutral"
322
327
  icon="i-lucide-chevron-left"
323
- label="이전"
328
+ :label="t('common.action.prev')"
324
329
  class="justify-self-start"
325
330
  :disabled="!previousPost?.id"
326
331
  @click="previousPost?.id && emit('navigate', previousPost)"
@@ -329,7 +334,7 @@ const contentHtml = computed(() => {
329
334
  variant="ghost"
330
335
  color="neutral"
331
336
  icon="i-lucide-list"
332
- label="목록"
337
+ :label="t('detail.list')"
333
338
  class="justify-self-center"
334
339
  @click="emit('list')"
335
340
  />
@@ -337,7 +342,7 @@ const contentHtml = computed(() => {
337
342
  variant="ghost"
338
343
  color="neutral"
339
344
  trailing-icon="i-lucide-chevron-right"
340
- label="다음"
345
+ :label="t('common.action.next')"
341
346
  class="justify-self-end"
342
347
  :disabled="!nextPost?.id"
343
348
  @click="nextPost?.id && emit('navigate', nextPost)"
@@ -348,7 +353,7 @@ const contentHtml = computed(() => {
348
353
 
349
354
  <section v-if="!isDraft" class="flex flex-col gap-4 border-t border-default pt-4">
350
355
  <h2 class="text-sm font-medium text-muted">
351
- 댓글
356
+ {{ t('detail.comments') }}
352
357
  </h2>
353
358
  <MemiBoardCommentList :board-id="boardId" :post-id="postId" />
354
359
  <MemiBoardCommentForm :board-id="boardId" :post-id="postId" />