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,17 +1,18 @@
1
1
  <script setup lang="ts">
2
- import { ref } from 'vue'
2
+ import { computed, ref } from 'vue'
3
+ import { useMemiBoardI18n } from 'memi-board/runtime'
3
4
 
4
- withDefaults(defineProps<{ introduction?: string }>(), {
5
- introduction: 'Nuxt 4와 Vue 3, TypeScript를 바탕으로 만들고 Nuxt UI와 Firebase로 글과 댓글을 자연스럽게 이어가는 게시판입니다.',
6
- })
5
+ const props = defineProps<{ introduction?: string }>()
6
+ const { t } = useMemiBoardI18n()
7
+ const introductionText = computed(() => props.introduction ?? t('footer.introduction'))
7
8
 
8
9
  const open = ref(false)
9
10
  const tab = ref<'about' | 'history'>('about')
10
- const stacks = [
11
- { title: '화면과 경험', icon: 'i-lucide-palette', text: 'Nuxt 4 · Vue 3 · TypeScript · Nuxt UI · Tailwind CSS' },
12
- { title: '데이터와 로그인', icon: 'i-lucide-database', text: 'Firebase Firestore · Auth · Storage · nuxt-vuefire' },
13
- { title: '글쓰기와 편의', icon: 'i-lucide-pencil-line', text: 'Tiptap · Lucide Icons · dayjs' },
14
- ]
11
+ const stacks = computed(() => [
12
+ { title: t('footer.stack.ui'), icon: 'i-lucide-palette', text: 'Nuxt 4 · Vue 3 · TypeScript · Nuxt UI · Tailwind CSS' },
13
+ { title: t('footer.stack.data'), icon: 'i-lucide-database', text: 'Firebase Firestore · Auth · Storage · nuxt-vuefire' },
14
+ { title: t('footer.stack.writing'), icon: 'i-lucide-pencil-line', text: 'Tiptap · Lucide Icons · Intl API' },
15
+ ])
15
16
  </script>
16
17
 
17
18
  <template>
@@ -22,18 +23,18 @@ const stacks = [
22
23
  </button>
23
24
  </footer>
24
25
 
25
- <UModal v-model:open="open" title="게시판 정보" :ui="{ content: 'sm:max-w-2xl' }">
26
+ <UModal v-model:open="open" :title="t('footer.info.title')" :ui="{ content: 'sm:max-w-2xl' }">
26
27
  <template #body>
27
28
  <div class="flex flex-col gap-4">
28
29
  <div class="flex gap-1 rounded-lg bg-muted p-1">
29
- <UButton size="sm" class="flex-1 justify-center" :color="tab === 'about' ? 'primary' : 'neutral'" :variant="tab === 'about' ? 'solid' : 'ghost'" @click="tab = 'about'">이 게시판은</UButton>
30
- <UButton size="sm" class="flex-1 justify-center" :color="tab === 'history' ? 'primary' : 'neutral'" :variant="tab === 'history' ? 'solid' : 'ghost'" @click="tab = 'history'">버전 히스토리</UButton>
30
+ <UButton size="sm" class="flex-1 justify-center" :color="tab === 'about' ? 'primary' : 'neutral'" :variant="tab === 'about' ? 'solid' : 'ghost'" @click="tab = 'about'">{{ t('footer.info.about') }}</UButton>
31
+ <UButton size="sm" class="flex-1 justify-center" :color="tab === 'history' ? 'primary' : 'neutral'" :variant="tab === 'history' ? 'solid' : 'ghost'" @click="tab = 'history'">{{ t('footer.info.history') }}</UButton>
31
32
  </div>
32
33
 
33
34
  <div v-if="tab === 'about'" class="max-h-[min(60vh,36rem)] space-y-4 overflow-y-auto px-1">
34
35
  <div class="flex items-start gap-3 px-1">
35
36
  <div class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary-500/10"><UIcon name="i-lucide-code-2" class="size-5 text-primary-500" /></div>
36
- <div><h3 class="font-bold text-highlighted">이 게시판은</h3><p class="mt-1 whitespace-pre-line text-sm leading-relaxed text-muted">{{ introduction }}</p></div>
37
+ <div><h3 class="font-bold text-highlighted">{{ t('footer.info.about') }}</h3><p class="mt-1 whitespace-pre-line text-sm leading-relaxed text-muted">{{ introductionText }}</p></div>
37
38
  </div>
38
39
  <section v-for="item in stacks" :key="item.title" class="rounded-2xl border border-default bg-default/70 p-4">
39
40
  <div class="flex items-center gap-3"><UIcon :name="item.icon" class="size-5 text-primary" /><div><h4 class="text-sm font-bold text-highlighted">{{ item.title }}</h4><p class="mt-1 text-sm text-muted">{{ item.text }}</p></div></div>
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
3
3
  import { useMemiBoardPostList } from 'memi-board/runtime'
4
- import { useMemiBoardSettings } from 'memi-board/runtime'
4
+ import { useMemiBoardI18n, useMemiBoardSettings } from 'memi-board/runtime'
5
5
  import type { BoardListView, PostModel } from 'memi-board/runtime'
6
6
  import MemiBoardListDefault from './ListDefault.vue'
7
7
  import MemiBoardListDense from './ListDense.vue'
@@ -55,7 +55,6 @@ const props = withDefaults(defineProps<{
55
55
  authorCommentsTo?: (authorUid: string) => string | undefined
56
56
  }>(), {
57
57
  pageSize: 10,
58
- introduction: '이 게시판은 Nuxt 4와 Vue 3, TypeScript를 바탕으로 만들었어요. Nuxt UI와 Tailwind CSS로 편안한 화면을 구성하고, Firebase Firestore·Auth·Storage와 nuxt-vuefire로 글과 댓글을 자연스럽게 이어갑니다.',
59
58
  canManageSettings: true,
60
59
  canWrite: true,
61
60
  })
@@ -64,6 +63,9 @@ const emit = defineEmits<{ select: [post: PostModel], 'update:view': [view: Boar
64
63
 
65
64
  const { boardDescription, getBoard } = useMemiBoardSettings()
66
65
  const resolvedBoardId = computed(() => props.boardId || props.category || '')
66
+ // 보드 설정 언어 → 호스트 언어 순. 자식 컴포넌트도 이 언어를 따른다.
67
+ const { t, dir } = useMemiBoardI18n({ boardLocale: () => getBoard(resolvedBoardId.value)?.locale })
68
+ const introductionText = computed(() => props.introduction ?? t('list.introduction'))
67
69
  const listDescription = computed(() => {
68
70
  const explicit = props.description?.trim()
69
71
  if (explicit) return explicit
@@ -134,43 +136,43 @@ const infoOpen = ref(false)
134
136
  const infoTab = ref<'about' | 'history'>('about')
135
137
  const now = ref(Date.now())
136
138
  let clock: ReturnType<typeof setInterval> | undefined
137
- const boardStacks = [
139
+ const boardStacks = computed(() => [
138
140
  {
139
- category: '프레임워크 & 언어',
141
+ category: t('list.stack.framework'),
140
142
  icon: 'i-lucide-layers',
141
143
  color: 'text-blue-500',
142
144
  panelClass: 'border-blue-100 bg-blue-50 dark:border-blue-900/50 dark:bg-blue-950/30',
143
145
  items: [
144
- { name: 'Nuxt 4', desc: 'Vue 기반 SSR 프레임워크', url: 'https://nuxt.com', icon: 'i-simple-icons-nuxtdotjs', color: '#00DC82' },
145
- { name: 'Vue 3', desc: 'Composition API · script setup', url: 'https://vuejs.org', icon: 'i-simple-icons-vuedotjs', color: '#4FC08D' },
146
- { name: 'TypeScript', desc: '안전한 데이터와 컴포넌트 타입', url: 'https://www.typescriptlang.org', icon: 'i-simple-icons-typescript', color: '#3178C6' },
146
+ { name: 'Nuxt 4', desc: t('list.tech.nuxt'), url: 'https://nuxt.com', icon: 'i-simple-icons-nuxtdotjs', color: '#00DC82' },
147
+ { name: 'Vue 3', desc: t('list.tech.vue'), url: 'https://vuejs.org', icon: 'i-simple-icons-vuedotjs', color: '#4FC08D' },
148
+ { name: 'TypeScript', desc: t('list.tech.typescript'), url: 'https://www.typescriptlang.org', icon: 'i-simple-icons-typescript', color: '#3178C6' },
147
149
  ],
148
150
  },
149
151
  {
150
- category: 'UI & 스타일',
152
+ category: t('list.stack.ui'),
151
153
  icon: 'i-lucide-palette',
152
154
  color: 'text-purple-500',
153
155
  panelClass: 'border-purple-100 bg-purple-50 dark:border-purple-900/50 dark:bg-purple-950/30',
154
156
  items: [
155
- { name: 'Nuxt UI v4', desc: '게시판·폼·모달 UI', url: 'https://ui.nuxt.com', icon: 'i-simple-icons-nuxtdotjs', color: '#00DC82' },
156
- { name: 'Tailwind CSS v4', desc: '반응형 · 라이트/다크 스타일', url: 'https://tailwindcss.com', icon: 'i-simple-icons-tailwindcss', color: '#06B6D4' },
157
- { name: 'Lucide Icons', desc: '가볍고 일관된 아이콘', url: 'https://lucide.dev', icon: 'i-lucide-box', color: '#F97316' },
158
- { name: 'dayjs', desc: '읽기 편한 날짜와 상대 시각', url: 'https://day.js.org', icon: 'i-lucide-clock', color: '#FF5F4C' },
157
+ { name: 'Nuxt UI v4', desc: t('list.tech.nuxtUi'), url: 'https://ui.nuxt.com', icon: 'i-simple-icons-nuxtdotjs', color: '#00DC82' },
158
+ { name: 'Tailwind CSS v4', desc: t('list.tech.tailwind'), url: 'https://tailwindcss.com', icon: 'i-simple-icons-tailwindcss', color: '#06B6D4' },
159
+ { name: 'Lucide Icons', desc: t('list.tech.lucide'), url: 'https://lucide.dev', icon: 'i-lucide-box', color: '#F97316' },
160
+ { name: 'Intl API', desc: t('list.tech.dayjs'), url: 'https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Intl', icon: 'i-lucide-clock', color: '#FF5F4C' },
159
161
  ],
160
162
  },
161
163
  {
162
- category: '백엔드 & 데이터',
164
+ category: t('list.stack.backend'),
163
165
  icon: 'i-lucide-database',
164
166
  color: 'text-orange-500',
165
167
  panelClass: 'border-orange-100 bg-orange-50 dark:border-orange-900/50 dark:bg-orange-950/30',
166
168
  items: [
167
- { name: 'Firebase Firestore', desc: '게시글과 댓글을 위한 실시간 DB', url: 'https://firebase.google.com', icon: 'i-simple-icons-firebase', color: '#FFCA28' },
168
- { name: 'Firebase Auth', desc: '안전한 사용자 로그인과 권한', url: 'https://firebase.google.com', icon: 'i-simple-icons-firebase', color: '#FFCA28' },
169
- { name: 'Firebase Storage', desc: '이미지와 첨부파일 보관', url: 'https://firebase.google.com', icon: 'i-simple-icons-firebase', color: '#FFCA28' },
170
- { name: 'nuxt-vuefire', desc: 'Nuxt와 Firebase 실시간 연결', url: 'https://vuefire.vuejs.org', icon: 'i-simple-icons-firebase', color: '#FFCA28' },
169
+ { name: 'Firebase Firestore', desc: t('list.tech.firestore'), url: 'https://firebase.google.com', icon: 'i-simple-icons-firebase', color: '#FFCA28' },
170
+ { name: 'Firebase Auth', desc: t('list.tech.auth'), url: 'https://firebase.google.com', icon: 'i-simple-icons-firebase', color: '#FFCA28' },
171
+ { name: 'Firebase Storage', desc: t('list.tech.storage'), url: 'https://firebase.google.com', icon: 'i-simple-icons-firebase', color: '#FFCA28' },
172
+ { name: 'nuxt-vuefire', desc: t('list.tech.vuefire'), url: 'https://vuefire.vuejs.org', icon: 'i-simple-icons-firebase', color: '#FFCA28' },
171
173
  ],
172
174
  },
173
- ]
175
+ ])
174
176
 
175
177
  onMounted(() => {
176
178
  clock = setInterval(() => { now.value = Date.now() }, 60_000)
@@ -209,7 +211,7 @@ function postTo(post: PostModel): string | undefined {
209
211
  </script>
210
212
 
211
213
  <template>
212
- <div class="flex flex-col gap-2">
214
+ <div class="flex flex-col gap-2" :dir="dir">
213
215
  <div v-if="hasHeader" class="flex items-center justify-between gap-4">
214
216
  <UTooltip
215
217
  v-if="title && listDescription"
@@ -231,7 +233,7 @@ function postTo(post: PostModel): string | undefined {
231
233
  <UButton
232
234
  v-if="settingsTo && canManageSettings"
233
235
  :to="settingsTo"
234
- label="설정"
236
+ :label="t('list.settings')"
235
237
  icon="i-lucide-settings"
236
238
  color="neutral"
237
239
  variant="outline"
@@ -243,7 +245,7 @@ function postTo(post: PostModel): string | undefined {
243
245
  <UButton
244
246
  v-if="writeTo && canWrite"
245
247
  :to="writeTo"
246
- label="글쓰기"
248
+ :label="t('common.action.write')"
247
249
  icon="i-lucide-pencil"
248
250
  size="sm"
249
251
  />
@@ -267,14 +269,14 @@ function postTo(post: PostModel): string | undefined {
267
269
  v-else-if="loadError"
268
270
  class="text-sm text-error text-center py-8"
269
271
  >
270
- 목록을 불러오지 못했습니다: {{ loadError }}
272
+ {{ t('list.loadFailed', { error: String(loadError) }) }}
271
273
  </p>
272
274
 
273
275
  <p
274
276
  v-else-if="!posts.length"
275
277
  class="text-sm text-muted text-center py-8"
276
278
  >
277
- 아직 게시글이 없습니다.
279
+ {{ t('list.empty') }}
278
280
  </p>
279
281
 
280
282
  <component
@@ -298,7 +300,7 @@ function postTo(post: PostModel): string | undefined {
298
300
  <UButton
299
301
  variant="outline"
300
302
  color="neutral"
301
- label="더 보기"
303
+ :label="t('common.action.more')"
302
304
  block
303
305
  class="w-full"
304
306
  :loading="morePending"
@@ -317,7 +319,7 @@ function postTo(post: PostModel): string | undefined {
317
319
 
318
320
  <UModal
319
321
  v-model:open="infoOpen"
320
- title="게시판 정보"
322
+ :title="t('list.info.title')"
321
323
  :ui="{ content: 'sm:max-w-2xl' }"
322
324
  >
323
325
  <template #body>
@@ -330,7 +332,7 @@ function postTo(post: PostModel): string | undefined {
330
332
  :variant="infoTab === 'about' ? 'solid' : 'ghost'"
331
333
  @click="infoTab = 'about'"
332
334
  >
333
- 이 게시판은
335
+ {{ t('list.info.about') }}
334
336
  </UButton>
335
337
  <UButton
336
338
  size="sm"
@@ -339,7 +341,7 @@ function postTo(post: PostModel): string | undefined {
339
341
  :variant="infoTab === 'history' ? 'solid' : 'ghost'"
340
342
  @click="infoTab = 'history'"
341
343
  >
342
- 버전 히스토리
344
+ {{ t('list.info.history') }}
343
345
  </UButton>
344
346
  </div>
345
347
 
@@ -356,10 +358,10 @@ function postTo(post: PostModel): string | undefined {
356
358
  </div>
357
359
  <div>
358
360
  <h3 class="font-bold text-highlighted">
359
- 이 게시판은
361
+ {{ t('list.info.about') }}
360
362
  </h3>
361
363
  <p class="mt-1 whitespace-pre-line text-sm leading-relaxed text-muted">
362
- {{ introduction }}
364
+ {{ introductionText }}
363
365
  </p>
364
366
  </div>
365
367
  </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 { formatRelativeDate, formatTimestampDetails, useMemiBoardSettings } from 'memi-board/runtime'
4
+ import { useMemiBoardI18n, useMemiBoardSettings } from 'memi-board/runtime'
5
5
  import MemiBoardAuthorMenu from './AuthorMenu.vue'
6
6
 
7
7
  withDefaults(defineProps<{
@@ -15,6 +15,7 @@ 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
19
  const NuxtLink = resolveComponent('NuxtLink')
19
20
 
20
21
  function image(post: PostModel): string | undefined {
@@ -50,14 +51,14 @@ function image(post: PostModel): string | undefined {
50
51
  <div class="flex min-w-0 flex-1 items-start gap-1.5">
51
52
  <UBadge
52
53
  v-if="post.isPublished === false"
53
- label="초안"
54
+ :label="t('common.label.draft')"
54
55
  color="warning"
55
56
  variant="subtle"
56
57
  size="sm"
57
58
  class="mt-0.5 shrink-0"
58
59
  />
59
60
  <h3 class="min-w-0 line-clamp-2 break-words text-sm font-medium leading-snug text-inherit sm:text-base">
60
- {{ post.title?.trim() || post.summary || '사진' }}
61
+ {{ post.title?.trim() || post.summary || t('list.photo') }}
61
62
  </h3>
62
63
  <span
63
64
  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 { formatRelativeDate, formatTimestampDetails, useMemiBoardSettings } from 'memi-board/runtime'
4
+ import { useMemiBoardI18n, useMemiBoardSettings } from 'memi-board/runtime'
5
5
  import MemiBoardAuthorMenu from './AuthorMenu.vue'
6
6
 
7
7
  withDefaults(defineProps<{
@@ -15,6 +15,7 @@ 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
19
  const NuxtLink = resolveComponent('NuxtLink')
19
20
 
20
21
  function thumb(post: PostModel): string | undefined {
@@ -36,14 +37,14 @@ function thumb(post: PostModel): string | undefined {
36
37
  >
37
38
  <UBadge
38
39
  v-if="post.isPublished === false"
39
- label="초안"
40
+ :label="t('common.label.draft')"
40
41
  color="warning"
41
42
  variant="subtle"
42
43
  size="sm"
43
44
  class="shrink-0"
44
45
  />
45
46
  <h3 class="min-w-0 truncate text-sm font-medium leading-snug text-inherit transition-colors group-hover:text-primary">
46
- {{ post.title?.trim() || post.summary || '사진' }}
47
+ {{ post.title?.trim() || post.summary || t('list.photo') }}
47
48
  </h3>
48
49
  <span
49
50
  v-if="(post.commentCount ?? 0) > 0"
@@ -92,7 +93,7 @@ function thumb(post: PostModel): string | undefined {
92
93
  <template v-if="(post.likeCount ?? 0) > 0">
93
94
  <span class="text-default/40" aria-hidden="true">|</span>
94
95
  <span class="inline-flex items-center gap-0.5 tabular-nums">
95
- 추천 {{ post.likeCount }}
96
+ {{ t('list.likeCount', { count: post.likeCount }) }}
96
97
  </span>
97
98
  </template>
98
99
  <template v-if="post.category && showCategory">
@@ -112,7 +113,7 @@ function thumb(post: PostModel): string | undefined {
112
113
  >
113
114
  <img
114
115
  :src="thumb(post)"
115
- :alt="post.title?.trim() || post.summary || '사진'"
116
+ :alt="post.title?.trim() || post.summary || t('list.photo')"
116
117
  class="size-full object-cover"
117
118
  >
118
119
  </component>
@@ -1,6 +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
5
 
5
6
  defineProps<{
6
7
  posts: PostModel[]
@@ -9,6 +10,7 @@ defineProps<{
9
10
  }>()
10
11
  const emit = defineEmits<{ select: [post: PostModel] }>()
11
12
  const NuxtLink = resolveComponent('NuxtLink')
13
+ const { t } = useMemiBoardI18n()
12
14
 
13
15
  function image(post: PostModel): string | undefined {
14
16
  return post.previewImage || post.attachments?.find(item => item.type.startsWith('image/'))?.url
@@ -28,7 +30,7 @@ function image(post: PostModel): string | undefined {
28
30
  <img
29
31
  v-if="image(post)"
30
32
  :src="image(post)"
31
- :alt="post.title?.trim() || post.summary || '이미지'"
33
+ :alt="post.title?.trim() || post.summary || t('common.label.image')"
32
34
  class="size-full object-cover transition duration-300 group-hover:scale-105"
33
35
  >
34
36
  <div
@@ -50,7 +52,7 @@ function image(post: PostModel): string | undefined {
50
52
  >
51
53
  <UBadge
52
54
  v-if="post.isPublished === false"
53
- label="초안"
55
+ :label="t('common.label.draft')"
54
56
  color="warning"
55
57
  variant="subtle"
56
58
  size="sm"
@@ -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 { formatRelativeDate, formatTimestampDetails, videoListCoverUrl } from 'memi-board/runtime'
4
+ import { useMemiBoardI18n, videoListCoverUrl } from 'memi-board/runtime'
5
5
  import MemiBoardAuthorMenu from './AuthorMenu.vue'
6
6
 
7
7
  defineProps<{
@@ -14,6 +14,7 @@ defineProps<{
14
14
  }>()
15
15
  const emit = defineEmits<{ select: [post: PostModel] }>()
16
16
  const NuxtLink = resolveComponent('NuxtLink')
17
+ const { t, formatRelativeDate, formatTimestampDetails } = useMemiBoardI18n()
17
18
 
18
19
  /** 영상 목록: YouTube URL 이 있으면 썸네일 우선, 그다음 본문/첨부 이미지. */
19
20
  function cover(post: PostModel): string | undefined {
@@ -46,7 +47,7 @@ function cover(post: PostModel): string | undefined {
46
47
  <div class="flex min-w-0 items-start gap-1.5">
47
48
  <UBadge
48
49
  v-if="post.isPublished === false"
49
- label="초안"
50
+ :label="t('common.label.draft')"
50
51
  color="warning"
51
52
  variant="subtle"
52
53
  size="sm"
@@ -1,17 +1,21 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from 'vue'
2
3
  import type { BoardListView } from 'memi-board/runtime'
4
+ import { useMemiBoardI18n } from 'memi-board/runtime'
3
5
 
4
6
  defineProps<{
5
7
  modelValue: BoardListView
6
8
  }>()
7
9
  const emit = defineEmits<{ 'update:modelValue': [value: BoardListView] }>()
8
10
 
9
- const options: Array<{ label: string, value: BoardListView, icon: string }> = [
10
- { label: '일반', value: 'default', icon: 'i-lucide-list' },
11
- { label: '조밀', value: 'dense', icon: 'i-lucide-rows-3' },
12
- { label: '이미지', value: 'image', icon: 'i-lucide-image' },
13
- { label: '영상', value: 'video', icon: 'i-lucide-play-circle' },
14
- ]
11
+ const { t } = useMemiBoardI18n()
12
+
13
+ const options = computed<Array<{ label: string, value: BoardListView, icon: string }>>(() => [
14
+ { label: t('listViewSwitch.default'), value: 'default', icon: 'i-lucide-list' },
15
+ { label: t('listViewSwitch.dense'), value: 'dense', icon: 'i-lucide-rows-3' },
16
+ { label: t('common.label.image'), value: 'image', icon: 'i-lucide-image' },
17
+ { label: t('listViewSwitch.video'), value: 'video', icon: 'i-lucide-play-circle' },
18
+ ])
15
19
  </script>
16
20
 
17
21
  <template>
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, resolveComponent } from 'vue'
3
3
  import type { PostModel } from 'memi-board/runtime'
4
- import { miniPostImage, miniPostLink, miniPostTitle } from 'memi-board/runtime'
4
+ import { miniPostImage, miniPostLink, miniPostTitle, useMemiBoardI18n } from 'memi-board/runtime'
5
5
 
6
6
  const props = defineProps<{
7
7
  post: PostModel
@@ -11,7 +11,8 @@ const props = defineProps<{
11
11
 
12
12
  const NuxtLink = resolveComponent('NuxtLink')
13
13
  const image = computed(() => miniPostImage(props.post))
14
- const title = computed(() => miniPostTitle(props.post))
14
+ const { locale } = useMemiBoardI18n()
15
+ const title = computed(() => miniPostTitle(props.post, locale.value))
15
16
  const to = computed(() => props.getPostLink?.(props.post) ?? miniPostLink(props.post))
16
17
  </script>
17
18
 
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, resolveComponent } from 'vue'
3
3
  import type { BoardSection, CommentModel } from 'memi-board/runtime'
4
- import { formatRelativeDate, miniCommentLink, useMemiBoardCommentFeed } from 'memi-board/runtime'
4
+ import { miniCommentLink, useMemiBoardCommentFeed, useMemiBoardI18n } from 'memi-board/runtime'
5
5
 
6
6
  const props = defineProps<{
7
7
  section: BoardSection
@@ -9,6 +9,7 @@ const props = defineProps<{
9
9
  }>()
10
10
 
11
11
  const NuxtLink = resolveComponent('NuxtLink')
12
+ const { t, formatRelativeDate } = useMemiBoardI18n()
12
13
  const now = ref(Date.now())
13
14
  const sort = computed(() => props.section.sort === 'likes' ? 'likes' : 'latest')
14
15
  const { comments, commentsPending } = useMemiBoardCommentFeed(sort, {
@@ -29,7 +30,7 @@ function commentTo(comment: CommentModel) {
29
30
  v-else-if="!comments.length"
30
31
  class="flex flex-1 items-center justify-center px-3 py-6 text-sm text-muted"
31
32
  >
32
- 아직 댓글이 없습니다.
33
+ {{ t('miniComments.empty') }}
33
34
  </p>
34
35
  <ul
35
36
  v-else
@@ -45,7 +46,7 @@ function commentTo(comment: CommentModel) {
45
46
  v-if="comment.isBlinded"
46
47
  class="truncate text-sm text-muted italic"
47
48
  >
48
- 관리자에 의해 블라인드된 댓글입니다.
49
+ {{ t('miniComments.blinded') }}
49
50
  </p>
50
51
  <p
51
52
  v-else
@@ -58,7 +59,7 @@ function commentTo(comment: CommentModel) {
58
59
  {{ comment.body }}
59
60
  </p>
60
61
  <p class="flex flex-wrap items-center gap-x-1.5 text-[11px] text-muted">
61
- <span>{{ comment.authorName || '익명' }}</span>
62
+ <span>{{ comment.authorName || t('common.label.anonymous') }}</span>
62
63
  <time>{{ formatRelativeDate(comment.createdAt, now) }}</time>
63
64
  <span
64
65
  v-if="(comment.likeCount ?? 0) > 0"
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
3
  import type { BoardSection, PostModel } from 'memi-board/runtime'
4
- import { miniBoardView, useMemiBoardMiniLatest, useMemiBoardSettings } from 'memi-board/runtime'
4
+ import { miniBoardView, useMemiBoardI18n, useMemiBoardMiniLatest, useMemiBoardSettings } from 'memi-board/runtime'
5
5
  import MemiBoardMiniPosts from './MiniPosts.vue'
6
6
 
7
7
  const props = defineProps<{
@@ -11,6 +11,7 @@ const props = defineProps<{
11
11
 
12
12
  const { posts, pending } = useMemiBoardMiniLatest(() => props.section.boardId, () => props.section.count)
13
13
  const { getBoard } = useMemiBoardSettings()
14
+ const { t } = useMemiBoardI18n()
14
15
  const view = computed(() => miniBoardView(props.section.boardId, getBoard))
15
16
  </script>
16
17
 
@@ -23,7 +24,7 @@ const view = computed(() => miniBoardView(props.section.boardId, getBoard))
23
24
  v-else-if="!posts.length"
24
25
  class="flex flex-1 items-center justify-center px-3 py-6 text-sm text-muted"
25
26
  >
26
- 아직 글이 없습니다.
27
+ {{ t('miniList.empty') }}
27
28
  </p>
28
29
  <MemiBoardMiniPosts
29
30
  v-else
@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
3
3
  import { getDocs, limit as fbLimit, orderBy, query, where } from 'firebase/firestore'
4
4
  import { useFirestore } from 'vuefire'
5
5
  import type { PostModel } from 'memi-board/runtime'
6
- import { miniPostTitle, postsCol, useBoardPathConfig, useMemiBoardSettings } from 'memi-board/runtime'
6
+ import { miniPostTitle, postsCol, useBoardPathConfig, useMemiBoardI18n, useMemiBoardSettings } from 'memi-board/runtime'
7
7
 
8
8
  const props = defineProps<{
9
9
  modelValue: string[]
@@ -17,6 +17,7 @@ const emit = defineEmits<{
17
17
  const db = useFirestore()
18
18
  const cfg = () => useBoardPathConfig()
19
19
  const { categoryLabel } = useMemiBoardSettings()
20
+ const { t, locale } = useMemiBoardI18n()
20
21
  const catalog = ref<PostModel[]>([])
21
22
  const pending = ref(true)
22
23
  const q = ref('')
@@ -28,7 +29,7 @@ const filtered = computed(() => {
28
29
  const rows = catalog.value.filter(post => !selected.value.includes(post.id || ''))
29
30
  if (!needle) return rows.slice(0, 20)
30
31
  return rows.filter((post) => {
31
- const title = miniPostTitle(post).toLowerCase()
32
+ const title = miniPostTitle(post, locale.value).toLowerCase()
32
33
  return title.includes(needle) || (post.slug || '').toLowerCase().includes(needle)
33
34
  }).slice(0, 20)
34
35
  })
@@ -71,14 +72,14 @@ function remove(id: string) {
71
72
  <div class="flex flex-col gap-3 border-t border-default p-3">
72
73
  <div class="flex flex-wrap items-center justify-between gap-2">
73
74
  <p class="text-xs text-muted">
74
- 고른 글 {{ selected.length }} / {{ max }}
75
- <span v-if="max >= 2"> · 2개부터 캐러셀, 4개는 그리드</span>
75
+ {{ t('miniPicker.selectedCount', { count: selected.length, max }) }}
76
+ <span v-if="max >= 2"> · {{ t('miniPicker.layoutHint') }}</span>
76
77
  </p>
77
78
  <UInput
78
79
  v-model="q"
79
80
  size="sm"
80
81
  icon="i-lucide-search"
81
- placeholder="제목 검색"
82
+ :placeholder="t('miniPicker.searchPlaceholder')"
82
83
  class="w-48"
83
84
  />
84
85
  </div>
@@ -90,7 +91,7 @@ function remove(id: string) {
90
91
  class="flex items-center gap-2 rounded-lg bg-elevated/50 px-2 py-1.5"
91
92
  >
92
93
  <p class="min-w-0 flex-1 truncate text-sm">
93
- {{ miniPostTitle(post) }}
94
+ {{ miniPostTitle(post, locale) }}
94
95
  <span class="text-xs text-muted">
95
96
  · {{ categoryLabel(post.category || post.boardId) }}
96
97
  </span>
@@ -106,7 +107,7 @@ function remove(id: string) {
106
107
  </div>
107
108
  </div>
108
109
 
109
- <p v-if="pending" class="text-xs text-muted">글을 불러오는 중…</p>
110
+ <p v-if="pending" class="text-xs text-muted">{{ t('miniPicker.loading') }}</p>
110
111
  <ul v-else class="flex max-h-48 flex-col gap-0.5 overflow-y-auto">
111
112
  <li v-for="post in filtered" :key="post.id">
112
113
  <button
@@ -116,14 +117,14 @@ function remove(id: string) {
116
117
  @click="add(post.id!)"
117
118
  >
118
119
  <UIcon name="i-lucide-plus" class="size-3.5 shrink-0 text-muted" />
119
- <span class="min-w-0 truncate">{{ miniPostTitle(post) }}</span>
120
+ <span class="min-w-0 truncate">{{ miniPostTitle(post, locale) }}</span>
120
121
  <span class="shrink-0 text-[11px] text-muted">
121
122
  {{ categoryLabel(post.category || post.boardId) }}
122
123
  </span>
123
124
  </button>
124
125
  </li>
125
126
  <li v-if="!filtered.length" class="px-2 py-3 text-center text-xs text-muted">
126
- 더 고를 글이 없습니다.
127
+ {{ t('miniPicker.noMore') }}
127
128
  </li>
128
129
  </ul>
129
130
  </div>
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
3
  import type { BoardSection, PostModel } from 'memi-board/runtime'
4
- import { miniPostsView, miniViewGroup, useMemiBoardMiniPicked, useMemiBoardSettings } from 'memi-board/runtime'
4
+ import { miniPostsView, miniViewGroup, useMemiBoardI18n, useMemiBoardMiniPicked, useMemiBoardSettings } from 'memi-board/runtime'
5
5
  import MemiBoardMiniCard from './MiniCard.vue'
6
6
  import MemiBoardMiniPosts from './MiniPosts.vue'
7
7
 
@@ -12,6 +12,7 @@ const props = defineProps<{
12
12
 
13
13
  const { posts, pending } = useMemiBoardMiniPicked(() => props.section.postIds)
14
14
  const { getBoard } = useMemiBoardSettings()
15
+ const { t } = useMemiBoardI18n()
15
16
  const view = computed(() => miniPostsView(posts.value, getBoard))
16
17
  const useCarousel = computed(() =>
17
18
  miniViewGroup(view.value) === 'list' && posts.value.length >= 2 && posts.value.length !== 4,
@@ -27,7 +28,7 @@ const useCarousel = computed(() =>
27
28
  v-else-if="!posts.length"
28
29
  class="flex flex-1 items-center justify-center px-3 py-6 text-sm text-muted"
29
30
  >
30
- 고른 게시물이 없습니다.
31
+ {{ t('miniPost.empty') }}
31
32
  </p>
32
33
  <UCarousel
33
34
  v-else-if="useCarousel"