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,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onMounted, ref, watch } from 'vue'
3
- import { slugify, useMemiBoardAuth, useMemiBoardSettings, useMemiBoardUsers } from 'memi-board/runtime'
3
+ import { 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
 
@@ -27,25 +27,35 @@ const emit = defineEmits<{
27
27
  saved: [categories: BoardCategory[]]
28
28
  }>()
29
29
 
30
- const listViewOptions: Array<{ label: string, value: BoardListView }> = [
31
- { label: '일반', value: 'default' },
32
- { label: '조밀', value: 'dense' },
33
- { label: '이미지', value: 'image' },
34
- { label: '영상', value: 'video' },
35
- ]
36
- const editorTypeOptions: Array<{ label: string, value: BoardEditorType, description: string }> = [
37
- { label: '일반', value: 'default', description: '제목과 본문을 작성' },
38
- { label: '이미지', value: 'image', description: '여러 사진과 대표사진을 작성' },
39
- ]
40
- const writeRoleOptions: Array<{ label: string, value: BoardWriteRole }> = [
41
- { label: '일반 이상', value: 'user' },
42
- { label: '스태프 이상', value: 'staff' },
43
- { label: '관리자만', value: 'admin' },
44
- ]
45
- const visibilityOptions: Array<{ label: string, value: BoardVisibility, description?: string }> = [
46
- { label: '보임', value: 'public', description: '전체 목록·필터에 표시' },
47
- { label: '숨김', value: 'hidden', description: '일기장·비공개. 댓글은 담당 스태프만' },
48
- ]
30
+ const { t } = useMemiBoardI18n()
31
+ const listViewOptions = computed<Array<{ label: string, value: BoardListView }>>(() => [
32
+ { label: t('settings.option.default'), value: 'default' },
33
+ { label: t('settings.listView.options.dense'), value: 'dense' },
34
+ { label: t('common.label.image'), value: 'image' },
35
+ { label: t('settings.listView.options.video'), value: 'video' },
36
+ ])
37
+ const editorTypeOptions = computed<Array<{ label: string, value: BoardEditorType, description: string }>>(() => [
38
+ { label: t('settings.option.default'), value: 'default', description: t('settings.editorType.options.default') },
39
+ { label: t('common.label.image'), value: 'image', description: t('settings.editorType.options.image') },
40
+ ])
41
+ const writeRoleOptions = computed<Array<{ label: string, value: BoardWriteRole }>>(() => [
42
+ { label: t('settings.writeRole.options.user'), value: 'user' },
43
+ { label: t('settings.writeRole.options.staff'), value: 'staff' },
44
+ { label: t('settings.writeRole.options.admin'), value: 'admin' },
45
+ ])
46
+ const visibilityOptions = computed<Array<{ label: string, value: BoardVisibility, description?: string }>>(() => [
47
+ { label: t('settings.visibility.options.public.label'), value: 'public', description: t('settings.visibility.options.public.description') },
48
+ { label: t('settings.visibility.options.hidden.label'), value: 'hidden', description: t('settings.visibility.options.hidden.description') },
49
+ ])
50
+ // reka-ui Select 는 빈 문자열 값을 허용하지 않아 '자동'을 sentinel 로 두고 저장 시 '' 로 바꾼다.
51
+ const LOCALE_AUTO = 'auto'
52
+ const localeOptions = computed(() => [
53
+ { label: t('settings.locale.auto'), value: LOCALE_AUTO },
54
+ ...MEMI_BOARD_LOCALES.map((item: { code: string, name: string }) => ({ label: item.name, value: item.code })),
55
+ ])
56
+ function fromLocaleOption(value: string) {
57
+ return value === LOCALE_AUTO ? '' : value
58
+ }
49
59
 
50
60
  const { isAdmin, rolePending } = useMemiBoardAuth()
51
61
  const { categories, settingsPending, saveCategory, saveCategories, deleteCategory } = useMemiBoardSettings()
@@ -91,6 +101,7 @@ watch([categories, settingsPending], ([list, loading]) => {
91
101
  writeRole: category.writeRole ?? 'user',
92
102
  commentWriteRole: category.commentWriteRole ?? 'user',
93
103
  allowedStaffUids: category.allowedStaffUids ?? [],
104
+ locale: category.locale ?? '',
94
105
  }))
95
106
  }
96
107
  }, { immediate: true })
@@ -112,6 +123,7 @@ function addCategory() {
112
123
  writeRole: 'user',
113
124
  commentWriteRole: 'user',
114
125
  allowedStaffUids: [],
126
+ locale: '',
115
127
  })
116
128
  newLabel.value = ''
117
129
  }
@@ -129,7 +141,7 @@ async function moveCategory(index: number, offset: -1 | 1) {
129
141
  await saveCategories(draft.value)
130
142
  }
131
143
  catch (cause) {
132
- error.value = cause instanceof Error ? cause.message : '카테고리 순서를 저장하지 못했습니다.'
144
+ error.value = cause instanceof Error ? cause.message : t('settings.error.orderFailed')
133
145
  }
134
146
  finally {
135
147
  ordering.value = false
@@ -139,7 +151,7 @@ async function moveCategory(index: number, offset: -1 | 1) {
139
151
  async function removeCategory(index: number) {
140
152
  const category = draft.value[index]
141
153
  if (!category || !window.confirm(
142
- `‘${category.label}’ 게시판을 삭제하시겠습니까?\n\n이 게시판의 모든 게시물, 댓글, 대댓글, 본문 이미지와 첨부파일이 영구 삭제됩니다. 이 작업은 되돌릴 수 없습니다.`,
154
+ t('settings.deleteConfirm', { label: category.label }),
143
155
  )) return
144
156
  error.value = ''
145
157
  deletingId.value = category.id
@@ -148,7 +160,7 @@ async function removeCategory(index: number) {
148
160
  draft.value.splice(index, 1)
149
161
  }
150
162
  catch (cause) {
151
- error.value = cause instanceof Error ? cause.message : '게시판과 연결 데이터를 삭제하지 못했습니다.'
163
+ error.value = cause instanceof Error ? cause.message : t('settings.error.deleteFailed')
152
164
  }
153
165
  finally {
154
166
  deletingId.value = null
@@ -171,7 +183,7 @@ async function save(category: BoardCategory, index: number) {
171
183
  emit('saved', draft.value)
172
184
  }
173
185
  catch (cause) {
174
- error.value = cause instanceof Error ? cause.message : '게시판 설정을 저장하지 못했습니다.'
186
+ error.value = cause instanceof Error ? cause.message : t('settings.error.saveFailed')
175
187
  }
176
188
  finally {
177
189
  savingId.value = null
@@ -179,7 +191,7 @@ async function save(category: BoardCategory, index: number) {
179
191
  }
180
192
 
181
193
  async function runDeleteAll() {
182
- if (!props.deleteAll || deleteConfirm.value !== '게시판 데이터 삭제') return
194
+ if (!props.deleteAll || deleteConfirm.value !== t('settings.danger.phrase')) return
183
195
  deletingAll.value = true
184
196
  deleteDone.value = false
185
197
  error.value = ''
@@ -189,7 +201,7 @@ async function runDeleteAll() {
189
201
  deleteDone.value = true
190
202
  }
191
203
  catch (cause) {
192
- error.value = cause instanceof Error ? cause.message : '게시판 데이터를 삭제하지 못했습니다.'
204
+ error.value = cause instanceof Error ? cause.message : t('settings.error.deleteAllFailed')
193
205
  }
194
206
  finally {
195
207
  deletingAll.value = false
@@ -198,14 +210,14 @@ async function runDeleteAll() {
198
210
  </script>
199
211
 
200
212
  <template>
201
- <div v-if="pending" class="py-16 text-center text-sm text-muted">설정을 불러오고 있습니다.</div>
213
+ <div v-if="pending" class="py-16 text-center text-sm text-muted">{{ t('settings.loading') }}</div>
202
214
  <UAlert
203
215
  v-else-if="!canManage"
204
216
  color="neutral"
205
217
  variant="subtle"
206
218
  icon="i-lucide-circle-help"
207
- title="없는 게시판입니다"
208
- description="주소를 다시 확인해 주세요."
219
+ :title="t('settings.notFound.title')"
220
+ :description="t('settings.notFound.checkAddress')"
209
221
  />
210
222
  <div v-else class="flex flex-col gap-4">
211
223
  <UAlert
@@ -213,8 +225,8 @@ async function runDeleteAll() {
213
225
  color="neutral"
214
226
  variant="subtle"
215
227
  icon="i-lucide-circle-help"
216
- title="없는 게시판입니다"
217
- description="게시판 주소를 다시 확인해 주세요."
228
+ :title="t('settings.notFound.title')"
229
+ :description="t('settings.notFound.checkBoardAddress')"
218
230
  />
219
231
  <details
220
232
  v-for="(category, index) in draft"
@@ -225,18 +237,18 @@ async function runDeleteAll() {
225
237
  <summary class="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 select-none [&::-webkit-details-marker]:hidden">
226
238
  <div class="min-w-0">
227
239
  <code class="text-sm font-semibold text-highlighted">{{ category.id }}</code>
228
- <p class="mt-1 truncate text-xs text-muted">{{ category.label || '라벨 없음' }} · {{ categoryTo(category.id) }}</p>
240
+ <p class="mt-1 truncate text-xs text-muted">{{ category.label || t('settings.noLabel') }} · {{ categoryTo(category.id) }}</p>
229
241
  </div>
230
242
  <UIcon name="i-lucide-chevron-down" class="size-4 text-muted transition-transform group-open:rotate-180" />
231
243
  </summary>
232
244
 
233
245
  <div class="flex flex-col gap-5 border-t border-default px-4 py-4">
234
246
  <div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
235
- <div><p class="text-sm font-medium">카테고리 ID</p><p class="text-xs text-muted">주소와 데이터 기준값</p></div>
247
+ <div><p class="text-sm font-medium">{{ t('settings.id.label') }}</p><p class="text-xs text-muted">{{ t('settings.id.help') }}</p></div>
236
248
  <UInput :model-value="category.id" disabled class="font-mono" />
237
249
  </div>
238
250
  <div class="flex flex-col gap-2">
239
- <div><p class="text-sm font-medium">글쓰기 권한</p><p class="text-xs text-muted">이 카테고리에 글을 쓸 수 있는 최소 역할</p></div>
251
+ <div><p class="text-sm font-medium">{{ t('settings.writeRole.label') }}</p><p class="text-xs text-muted">{{ t('settings.writeRole.help') }}</p></div>
240
252
  <MemiBoardOptionCards
241
253
  v-model="category.writeRole"
242
254
  :options="writeRoleOptions"
@@ -245,11 +257,11 @@ async function runDeleteAll() {
245
257
  </div>
246
258
  <div class="flex flex-col gap-2">
247
259
  <div>
248
- <p class="text-sm font-medium">댓글쓰기 권한</p>
260
+ <p class="text-sm font-medium">{{ t('settings.commentWriteRole.label') }}</p>
249
261
  <p class="text-xs text-muted">
250
262
  {{ category.visibility === 'hidden'
251
- ? '숨김 게시판은 댓글이 공개 피드에 안 나오고, 담당 스태프만 작성할 수 있습니다'
252
- : '이 카테고리에 댓글을 쓸 수 있는 최소 역할' }}
263
+ ? t('settings.commentWriteRole.hiddenHelp')
264
+ : t('settings.commentWriteRole.help') }}
253
265
  </p>
254
266
  </div>
255
267
  <MemiBoardOptionCards
@@ -262,40 +274,40 @@ async function runDeleteAll() {
262
274
  v-else
263
275
  class="rounded-lg border border-default px-3 py-2.5 text-sm text-muted"
264
276
  >
265
- 담당 스태프만 (숨김 보드 고정)
277
+ {{ t('settings.commentWriteRole.hiddenFixed') }}
266
278
  </p>
267
279
  </div>
268
280
  <div v-if="needsStaffPicker(category)" class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
269
- <div><p class="text-sm font-medium">담당 스태프</p><p class="text-xs text-muted">지정한 스태프만 이 보드를 설정·관리합니다. 비우면 관리자만</p></div>
281
+ <div><p class="text-sm font-medium">{{ t('settings.staff.label') }}</p><p class="text-xs text-muted">{{ t('settings.staff.help') }}</p></div>
270
282
  <USelectMenu
271
283
  v-model="category.allowedStaffUids"
272
284
  :items="staffOptions"
273
285
  value-key="value"
274
286
  label-key="label"
275
287
  multiple
276
- placeholder="관리자만 (스태프 미지정)"
288
+ :placeholder="t('settings.staff.placeholder')"
277
289
  @update:model-value="savedId = null"
278
290
  />
279
291
  </div>
280
292
  <div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
281
- <div><p class="text-sm font-medium">표시 라벨</p><p class="text-xs text-muted">사용자에게 보이는 이름</p></div>
293
+ <div><p class="text-sm font-medium">{{ t('settings.label.label') }}</p><p class="text-xs text-muted">{{ t('settings.label.help') }}</p></div>
282
294
  <UInput v-model="category.label" @update:model-value="savedId = null" />
283
295
  </div>
284
296
  <div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-start">
285
- <div><p class="text-sm font-medium">설명</p><p class="text-xs text-muted">게시판 상단 등에 보이는 안내 문구</p></div>
297
+ <div><p class="text-sm font-medium">{{ t('settings.description.label') }}</p><p class="text-xs text-muted">{{ t('settings.description.help') }}</p></div>
286
298
  <UTextarea
287
299
  v-model="category.description"
288
300
  :rows="3"
289
301
  autoresize
290
302
  :maxrows="6"
291
- placeholder="이 게시판에 대한 짧은 설명 (선택)"
303
+ :placeholder="t('settings.description.placeholder')"
292
304
  @update:model-value="savedId = null"
293
305
  />
294
306
  </div>
295
307
  <div v-if="canManageStaffAssignment" class="flex flex-col gap-2">
296
308
  <div>
297
- <p class="text-sm font-medium">공개 범위</p>
298
- <p class="text-xs text-muted">숨김이면 전체 필터에 안 나오고, 글은 관리자·담당 스태프·작성자만 읽을 수 있습니다. 댓글은 담당 스태프만 쓸 수 있습니다</p>
309
+ <p class="text-sm font-medium">{{ t('settings.visibility.label') }}</p>
310
+ <p class="text-xs text-muted">{{ t('settings.visibility.help') }}</p>
299
311
  </div>
300
312
  <MemiBoardOptionCards
301
313
  v-model="category.visibility"
@@ -304,7 +316,7 @@ async function runDeleteAll() {
304
316
  />
305
317
  </div>
306
318
  <div class="flex flex-col gap-2">
307
- <div><p class="text-sm font-medium">리스트뷰</p><p class="text-xs text-muted">게시글 목록 표시 방식</p></div>
319
+ <div><p class="text-sm font-medium">{{ t('settings.listView.label') }}</p><p class="text-xs text-muted">{{ t('settings.listView.help') }}</p></div>
308
320
  <MemiBoardOptionCards
309
321
  v-model="category.listView"
310
322
  :options="listViewOptions"
@@ -312,21 +324,31 @@ async function runDeleteAll() {
312
324
  />
313
325
  </div>
314
326
  <div class="flex flex-col gap-2">
315
- <div><p class="text-sm font-medium">입력 폼</p><p class="text-xs text-muted">글쓰기 화면의 작성 방식</p></div>
327
+ <div><p class="text-sm font-medium">{{ t('settings.editorType.label') }}</p><p class="text-xs text-muted">{{ t('settings.editorType.help') }}</p></div>
316
328
  <MemiBoardOptionCards
317
329
  v-model="category.editorType"
318
330
  :options="editorTypeOptions"
319
331
  @update:model-value="savedId = null"
320
332
  />
321
333
  </div>
334
+ <div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
335
+ <div><p class="text-sm font-medium">{{ t('settings.locale.label') }}</p><p class="text-xs text-muted">{{ t('settings.locale.help') }}</p></div>
336
+ <USelect
337
+ :model-value="category.locale || LOCALE_AUTO"
338
+ :items="localeOptions"
339
+ value-key="value"
340
+ label-key="label"
341
+ @update:model-value="category.locale = fromLocaleOption($event as string); savedId = null"
342
+ />
343
+ </div>
322
344
  <div class="flex flex-wrap justify-between gap-2 border-t border-default pt-4">
323
345
  <div v-if="!categoryId" class="flex gap-1">
324
- <UButton label="위로" icon="i-lucide-arrow-up" color="neutral" variant="outline" size="sm" :disabled="index === 0 || ordering" @click="moveCategory(index, -1)" />
325
- <UButton label="아래로" icon="i-lucide-arrow-down" color="neutral" variant="outline" size="sm" :disabled="index === draft.length - 1 || ordering" @click="moveCategory(index, 1)" />
346
+ <UButton :label="t('common.action.toTop')" icon="i-lucide-arrow-up" color="neutral" variant="outline" size="sm" :disabled="index === 0 || ordering" @click="moveCategory(index, -1)" />
347
+ <UButton :label="t('settings.action.moveDown')" icon="i-lucide-arrow-down" color="neutral" variant="outline" size="sm" :disabled="index === draft.length - 1 || ordering" @click="moveCategory(index, 1)" />
326
348
  </div>
327
349
  <div class="flex gap-1">
328
350
  <UButton
329
- label="게시판으로 이동"
351
+ :label="t('settings.action.goToBoard')"
330
352
  icon="i-lucide-arrow-up-right"
331
353
  color="neutral"
332
354
  variant="ghost"
@@ -335,7 +357,7 @@ async function runDeleteAll() {
335
357
  />
336
358
  <UButton
337
359
  v-if="!categoryId"
338
- label="게시판 삭제"
360
+ :label="t('settings.action.deleteBoard')"
339
361
  icon="i-lucide-trash-2"
340
362
  color="error"
341
363
  variant="ghost"
@@ -345,7 +367,7 @@ async function runDeleteAll() {
345
367
  @click="removeCategory(index)"
346
368
  />
347
369
  <UButton
348
- label="저장"
370
+ :label="t('common.action.save')"
349
371
  icon="i-lucide-save"
350
372
  size="sm"
351
373
  :loading="savingId === category.id"
@@ -357,33 +379,33 @@ async function runDeleteAll() {
357
379
  </details>
358
380
 
359
381
  <div v-if="!categoryId" class="flex gap-2 border-t border-default pt-4">
360
- <UInput v-model="newLabel" class="flex-1" placeholder="새 카테고리 이름" @keyup.enter="addCategory" />
361
- <UButton label="추가" icon="i-lucide-plus" color="neutral" variant="outline" @click="addCategory" />
382
+ <UInput v-model="newLabel" class="flex-1" :placeholder="t('settings.newCategoryPlaceholder')" @keyup.enter="addCategory" />
383
+ <UButton :label="t('common.action.add')" icon="i-lucide-plus" color="neutral" variant="outline" @click="addCategory" />
362
384
  </div>
363
385
  <UAlert v-if="error" color="error" variant="subtle" :description="error" />
364
- <UAlert v-if="savedId" color="success" variant="subtle" :description="`‘${draft.find(item => item.id === savedId)?.label}’ 카테고리를 저장했습니다.`" />
386
+ <UAlert v-if="savedId" color="success" variant="subtle" :description="t('settings.savedCategory', { label: draft.find(item => item.id === savedId)?.label })" />
365
387
 
366
388
  <section v-if="deleteAll && !categoryId" class="mt-4 flex flex-col gap-3 border-t border-error/40 pt-6">
367
389
  <div>
368
- <h2 class="font-semibold text-error">위험 영역</h2>
390
+ <h2 class="font-semibold text-error">{{ t('settings.danger.title') }}</h2>
369
391
  <p class="mt-1 text-sm text-muted">
370
- 모든 게시글, 댓글, 첨부파일과 카테고리 설정을 영구 삭제합니다. 게시판 사용자와 관리자 권한은 유지합니다.
392
+ {{ t('settings.danger.description') }}
371
393
  </p>
372
394
  </div>
373
- <UFormField label="확인을 위해 ‘게시판 데이터 삭제’를 입력하세요">
374
- <UInput v-model="deleteConfirm" autocomplete="off" placeholder="게시판 데이터 삭제" />
395
+ <UFormField :label="t('settings.danger.confirmLabel', { phrase: t('settings.danger.phrase') })">
396
+ <UInput v-model="deleteConfirm" autocomplete="off" :placeholder="t('settings.danger.phrase')" />
375
397
  </UFormField>
376
398
  <div class="flex justify-end">
377
399
  <UButton
378
- label="모든 데이터 삭제"
400
+ :label="t('settings.danger.button')"
379
401
  icon="i-lucide-bomb"
380
402
  color="error"
381
- :disabled="deleteConfirm !== '게시판 데이터 삭제'"
403
+ :disabled="deleteConfirm !== t('settings.danger.phrase')"
382
404
  :loading="deletingAll"
383
405
  @click="runDeleteAll"
384
406
  />
385
407
  </div>
386
- <UAlert v-if="deleteDone" color="success" variant="subtle" description="게시판 데이터를 모두 삭제했습니다." />
408
+ <UAlert v-if="deleteDone" color="success" variant="subtle" :description="t('settings.danger.done')" />
387
409
  </section>
388
410
  </div>
389
411
  </template>
@@ -1,10 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import { onBeforeUnmount, ref } from 'vue'
3
+ import { useMemiBoardI18n } from 'memi-board/runtime'
3
4
 
4
5
  const props = defineProps<{
5
6
  title?: string | null
6
7
  }>()
7
8
 
9
+ const { t } = useMemiBoardI18n()
8
10
  const copied = ref(false)
9
11
  let copiedTimer: ReturnType<typeof setTimeout> | undefined
10
12
 
@@ -43,7 +45,7 @@ onBeforeUnmount(() => {
43
45
  size="sm"
44
46
  variant="ghost"
45
47
  :color="copied ? 'success' : 'neutral'"
46
- :label="copied ? '링크 복사됨' : '공유'"
48
+ :label="copied ? t('shareButton.copied') : t('common.action.share')"
47
49
  @click="handleShare"
48
50
  />
49
51
  </template>
@@ -1,9 +1,12 @@
1
1
  <script setup lang="ts">
2
+ import { imeSafeSubmitClick, imeSafeSubmitPointerDown } from 'memi-board/runtime'
2
3
  import { ref, computed } from 'vue'
3
4
  import { useMemiBoardConfig } from 'memi-board/runtime'
4
5
  import { useMemiBoardAuth } from 'memi-board/runtime'
6
+ import { useMemiBoardI18n } from 'memi-board/runtime'
5
7
 
6
8
  const config = useMemiBoardConfig()
9
+ const { t } = useMemiBoardI18n()
7
10
  const providers = computed(() => config.auth?.providers ?? ['google', 'apple'])
8
11
  const hasOAuthProvider = computed(() => providers.value.includes('google') || providers.value.includes('apple'))
9
12
 
@@ -45,6 +48,7 @@ async function handleApple() {
45
48
  }
46
49
 
47
50
  async function handleEmailSubmit() {
51
+ if (loading.value) return
48
52
  loading.value = true
49
53
  error.value = ''
50
54
  try {
@@ -71,7 +75,7 @@ async function handleEmailSubmit() {
71
75
  icon="i-simple-icons-google"
72
76
  color="neutral"
73
77
  variant="outline"
74
- label="Google로 로그인"
78
+ :label="t('signIn.withGoogle')"
75
79
  block
76
80
  :loading="loading"
77
81
  @click="handleGoogle"
@@ -82,7 +86,7 @@ async function handleEmailSubmit() {
82
86
  icon="i-simple-icons-apple"
83
87
  color="neutral"
84
88
  variant="outline"
85
- label="Apple로 로그인"
89
+ :label="t('signIn.withApple')"
86
90
  block
87
91
  :loading="loading"
88
92
  @click="handleApple"
@@ -94,7 +98,7 @@ async function handleEmailSubmit() {
94
98
  class="flex items-center gap-2 text-xs text-muted"
95
99
  >
96
100
  <div class="flex-1 border-t border-default" />
97
- 또는
101
+ {{ t('signIn.or') }}
98
102
  <div class="flex-1 border-t border-default" />
99
103
  </div>
100
104
 
@@ -105,25 +109,27 @@ async function handleEmailSubmit() {
105
109
  <UInput
106
110
  v-if="mode === 'signup'"
107
111
  v-model="displayName"
108
- placeholder="이름"
112
+ :placeholder="t('signIn.name')"
109
113
  />
110
114
  <UInput
111
115
  v-model="email"
112
116
  type="email"
113
- placeholder="이메일"
117
+ :placeholder="t('signIn.email')"
114
118
  required
115
119
  />
116
120
  <UInput
117
121
  v-model="password"
118
122
  type="password"
119
- placeholder="비밀번호"
123
+ :placeholder="t('signIn.password')"
120
124
  required
121
125
  />
122
126
  <UButton
123
127
  type="submit"
128
+ @pointerdown="imeSafeSubmitPointerDown"
129
+ @click="imeSafeSubmitClick"
124
130
  block
125
131
  :loading="loading"
126
- :label="mode === 'signin' ? '로그인' : '회원가입'"
132
+ :label="mode === 'signin' ? t('common.action.signIn') : t('signIn.signUp')"
127
133
  />
128
134
  </form>
129
135
 
@@ -131,7 +137,7 @@ async function handleEmailSubmit() {
131
137
  variant="link"
132
138
  size="sm"
133
139
  color="neutral"
134
- :label="mode === 'signin' ? '계정이 없으신가요? 회원가입' : '이미 계정이 있으신가요? 로그인'"
140
+ :label="mode === 'signin' ? t('signIn.toSignUp') : t('signIn.toSignIn')"
135
141
  @click="mode = mode === 'signin' ? 'signup' : 'signin'"
136
142
  />
137
143
  </template>
@@ -4,10 +4,12 @@
4
4
  * 닫으면 localStorage에 타임스탬프 저장 → 24시간 지나면 다시 표시.
5
5
  */
6
6
  import { onMounted, ref } from 'vue'
7
+ import { useMemiBoardI18n } from 'memi-board/runtime'
7
8
 
8
9
  const STORAGE_KEY = 'memi-board-post-swipe-hint-dismissed-at'
9
10
  const DAY_MS = 24 * 60 * 60 * 1000
10
11
 
12
+ const { t } = useMemiBoardI18n()
11
13
  const allowed = ref(false)
12
14
 
13
15
  onMounted(() => {
@@ -61,10 +63,10 @@ function dismiss() {
61
63
  </div>
62
64
  <div class="min-w-0 flex-1 pt-0.5">
63
65
  <p class="text-sm font-medium text-highlighted">
64
- 좌우로 스와이프해 보세요
66
+ {{ t('swipeHint.title') }}
65
67
  </p>
66
68
  <p class="mt-0.5 text-xs leading-relaxed text-muted">
67
- 왼쪽으로 밀면 다음 글, 오른쪽으로 밀면 이전 글로 이동해요.
69
+ {{ t('swipeHint.description') }}
68
70
  </p>
69
71
  </div>
70
72
  <UButton
@@ -74,7 +76,7 @@ function dismiss() {
74
76
  size="sm"
75
77
  square
76
78
  class="shrink-0"
77
- aria-label="닫기"
79
+ :aria-label="t('common.action.close')"
78
80
  @click="dismiss"
79
81
  />
80
82
  </div>
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, resolveComponent } from 'vue'
3
- import { formatRelativeDate, useMemiBoardUserComments } from 'memi-board/runtime'
3
+ import { useMemiBoardI18n, useMemiBoardUserComments } from 'memi-board/runtime'
4
4
  import type { CommentModel } from 'memi-board/runtime'
5
5
 
6
6
  const NuxtLink = resolveComponent('NuxtLink')
@@ -20,6 +20,8 @@ const props = withDefaults(defineProps<{
20
20
 
21
21
  const emit = defineEmits<{ select: [comment: CommentModel] }>()
22
22
 
23
+ const { t, formatRelativeDate } = useMemiBoardI18n()
24
+
23
25
  const { comments, commentsPending, hasMore, loadingMore, loadError, loadMore } = useMemiBoardUserComments(
24
26
  () => props.uid,
25
27
  { pageSize: props.pageSize },
@@ -46,14 +48,14 @@ function commentLink(comment: CommentModel): string | undefined {
46
48
  v-else-if="loadError"
47
49
  class="text-sm text-error text-center py-8"
48
50
  >
49
- 목록을 불러오지 못했습니다: {{ loadError }}
51
+ {{ t('userCommentList.loadFailed', { error: String(loadError) }) }}
50
52
  </p>
51
53
 
52
54
  <p
53
55
  v-else-if="!comments.length"
54
56
  class="text-sm text-muted text-center py-8"
55
57
  >
56
- 작성한 댓글이 없습니다.
58
+ {{ t('userCommentList.empty') }}
57
59
  </p>
58
60
 
59
61
  <ul
@@ -75,7 +77,7 @@ function commentLink(comment: CommentModel): string | undefined {
75
77
  v-if="comment.isBlinded"
76
78
  class="text-sm text-muted italic"
77
79
  >
78
- 관리자에 의해 블라인드된 댓글입니다.
80
+ {{ t('userCommentList.blinded') }}
79
81
  </p>
80
82
  <p
81
83
  v-else
@@ -86,7 +88,7 @@ function commentLink(comment: CommentModel): string | undefined {
86
88
  <p class="mt-1 text-xs text-muted">
87
89
  {{ formatRelativeDate(comment.createdAt, now) }}
88
90
  <template v-if="(comment.likeCount ?? 0) > 0">
89
- · 좋아요 {{ comment.likeCount }}
91
+ · {{ t('userCommentList.likes', { count: comment.likeCount ?? 0 }) }}
90
92
  </template>
91
93
  </p>
92
94
  </component>
@@ -100,7 +102,7 @@ function commentLink(comment: CommentModel): string | undefined {
100
102
  <UButton
101
103
  variant="outline"
102
104
  color="neutral"
103
- label="더 보기"
105
+ :label="t('common.action.more')"
104
106
  block
105
107
  class="w-full"
106
108
  :loading="loadingMore"
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { ref } from 'vue'
3
- import { useMemiBoardUserPosts } from 'memi-board/runtime'
3
+ import { useMemiBoardI18n, useMemiBoardUserPosts } from 'memi-board/runtime'
4
4
  import type { PostModel, UserPostModel } from 'memi-board/runtime'
5
5
  import MemiBoardListDefault from './ListDefault.vue'
6
6
 
@@ -25,6 +25,8 @@ const props = withDefaults(defineProps<{
25
25
 
26
26
  const emit = defineEmits<{ select: [post: UserPostModel] }>()
27
27
 
28
+ const { t } = useMemiBoardI18n()
29
+
28
30
  const { posts, postsPending, hasMore, loadingMore, loadError, loadMore } = useMemiBoardUserPosts(
29
31
  () => props.uid,
30
32
  { pageSize: props.pageSize },
@@ -51,14 +53,14 @@ function postTo(post: PostModel): string | undefined {
51
53
  v-else-if="loadError"
52
54
  class="text-sm text-error text-center py-8"
53
55
  >
54
- 목록을 불러오지 못했습니다: {{ loadError }}
56
+ {{ t('userPostList.loadFailed', { error: String(loadError) }) }}
55
57
  </p>
56
58
 
57
59
  <p
58
60
  v-else-if="!posts.length"
59
61
  class="text-sm text-muted text-center py-8"
60
62
  >
61
- 작성한 글이 없습니다.
63
+ {{ t('userPostList.empty') }}
62
64
  </p>
63
65
 
64
66
  <MemiBoardListDefault
@@ -80,7 +82,7 @@ function postTo(post: PostModel): string | undefined {
80
82
  <UButton
81
83
  variant="outline"
82
84
  color="neutral"
83
- label="더 보기"
85
+ :label="t('common.action.more')"
84
86
  block
85
87
  class="w-full"
86
88
  :loading="loadingMore"