memi-board 0.56.2 → 0.58.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 (78) hide show
  1. package/README.md +55 -9
  2. package/dist/{ar-CQhBdHu3.js → ar-GNguZBN4.js} +45 -4
  3. package/dist/ar-GNguZBN4.js.map +1 -0
  4. package/dist/{commentImages-DsW2yCqu.js → commentImages-CRK_2uVi.js} +195 -70
  5. package/dist/commentImages-CRK_2uVi.js.map +1 -0
  6. package/dist/composables/useMemiBoardModeration.d.ts +2 -0
  7. package/dist/composables/useMemiBoardPosts.d.ts +1 -1
  8. package/dist/composables/useMemiBoardReports.d.ts +102 -3
  9. package/dist/composables/useMemiBoardUserDirectory.d.ts +20 -0
  10. package/dist/composables/useMemiBoardUserProfile.d.ts +1 -1
  11. package/dist/composables/useMemiBoardUsers.d.ts +1 -1
  12. package/dist/{de-B4GZqxQj.js → de-CS0iRLqe.js} +45 -4
  13. package/dist/de-CS0iRLqe.js.map +1 -0
  14. package/dist/{en-BDkfivhc.js → en-DeExi-DR.js} +129 -4
  15. package/dist/en-DeExi-DR.js.map +1 -0
  16. package/dist/{es-DzFDaLwo.js → es-dlEFrox-.js} +45 -4
  17. package/dist/es-dlEFrox-.js.map +1 -0
  18. package/dist/{fr-Zf5mcs8m.js → fr-B9sjKJF4.js} +45 -4
  19. package/dist/fr-B9sjKJF4.js.map +1 -0
  20. package/dist/{id-CaWN8212.js → id-Ca7skouh.js} +45 -4
  21. package/dist/id-Ca7skouh.js.map +1 -0
  22. package/dist/index.d.ts +3 -1
  23. package/dist/index.js +1089 -829
  24. package/dist/index.js.map +1 -1
  25. package/dist/{ja-xDttH9CE.js → ja-ByVLGIDd.js} +45 -4
  26. package/dist/ja-ByVLGIDd.js.map +1 -0
  27. package/dist/module.mjs +4 -0
  28. package/dist/module.mjs.map +1 -1
  29. package/dist/{pt-WIfxYYWQ.js → pt-COQL_Q7A.js} +45 -4
  30. package/dist/pt-COQL_Q7A.js.map +1 -0
  31. package/dist/runtime/components/AccountDeletion.vue +64 -0
  32. package/dist/runtime/components/AuthorMenu.vue +4 -4
  33. package/dist/runtime/components/CommentFeed.vue +17 -13
  34. package/dist/runtime/components/CommentForm.vue +58 -56
  35. package/dist/runtime/components/CommentImageField.vue +18 -13
  36. package/dist/runtime/components/CommentItem.vue +95 -60
  37. package/dist/runtime/components/CommentList.vue +10 -1
  38. package/dist/runtime/components/CommentThread.vue +16 -2
  39. package/dist/runtime/components/CustomFieldEditor.vue +10 -8
  40. package/dist/runtime/components/Detail.vue +18 -10
  41. package/dist/runtime/components/Editor.vue +1 -5
  42. package/dist/runtime/components/ImageLightbox.vue +126 -58
  43. package/dist/runtime/components/LikeButton.vue +14 -11
  44. package/dist/runtime/components/ListViewSwitch.vue +10 -10
  45. package/dist/runtime/components/Profile.vue +14 -2
  46. package/dist/runtime/components/ProfileEditModal.vue +20 -3
  47. package/dist/runtime/components/ReportButton.vue +22 -13
  48. package/dist/runtime/components/ReportList.vue +77 -93
  49. package/dist/runtime/components/UserListControls.vue +29 -0
  50. package/dist/runtime/components/UserListPagination.vue +17 -0
  51. package/dist/runtime/components/UserManagementEmail.vue +21 -0
  52. package/dist/runtime/components/UserRoleGuide.vue +38 -0
  53. package/dist/runtime/components/Users.vue +21 -59
  54. package/dist/runtime/components/UsersSettings.vue +119 -0
  55. package/dist/storage.js +1 -1
  56. package/dist/types.d.ts +5 -0
  57. package/dist/utils/postSlug.d.ts +12 -0
  58. package/dist/utils/profileNameModeration.d.ts +8 -0
  59. package/dist/utils/userDirectoryPage.d.ts +19 -0
  60. package/dist/{zh-CI6Sd8PD.js → zh-DBjX9qAS.js} +45 -4
  61. package/dist/zh-DBjX9qAS.js.map +1 -0
  62. package/docs/account-deletion.md +60 -0
  63. package/docs/firebase-setup.md +77 -0
  64. package/docs/firestore.rules.example +78 -4
  65. package/docs/host-integration.md +85 -11
  66. package/docs/storage.rules.example +7 -2
  67. package/package.json +3 -2
  68. package/scripts/backfill-post-slugs.mjs +38 -0
  69. package/dist/ar-CQhBdHu3.js.map +0 -1
  70. package/dist/commentImages-DsW2yCqu.js.map +0 -1
  71. package/dist/de-B4GZqxQj.js.map +0 -1
  72. package/dist/en-BDkfivhc.js.map +0 -1
  73. package/dist/es-DzFDaLwo.js.map +0 -1
  74. package/dist/fr-Zf5mcs8m.js.map +0 -1
  75. package/dist/id-CaWN8212.js.map +0 -1
  76. package/dist/ja-xDttH9CE.js.map +0 -1
  77. package/dist/pt-WIfxYYWQ.js.map +0 -1
  78. package/dist/zh-CI6Sd8PD.js.map +0 -1
@@ -9,11 +9,77 @@ const props = defineProps<{
9
9
  images: string[]
10
10
  }>()
11
11
 
12
- const { t, dir } = useMemiBoardI18n()
12
+ const { t, dir, formatNumber } = useMemiBoardI18n()
13
13
  const closeButton = ref<HTMLButtonElement | null>(null)
14
14
  const src = computed(() => props.images[index.value] ?? '')
15
15
  const multiple = computed(() => props.images.length > 1)
16
16
 
17
+ const stage = ref<HTMLElement | null>(null)
18
+ const natural = ref({ width: 0, height: 0 })
19
+ const viewport = ref({ width: 0, height: 0 })
20
+ const zoom = ref(1)
21
+ const mode = ref<'fit' | 'manual'>('fit')
22
+ const fitScale = computed(() => natural.value.width && viewport.value.width
23
+ ? Math.min(8, Math.max(1, viewport.value.width - 32) / natural.value.width, Math.max(1, viewport.value.height - 32) / natural.value.height)
24
+ : 1)
25
+ const minScale = computed(() => Math.min(0.1, fitScale.value))
26
+ const scale = computed(() => mode.value === 'fit' ? fitScale.value : zoom.value)
27
+ const imageWidth = computed(() => Math.max(1, Math.round(natural.value.width * scale.value)))
28
+ const imageHeight = computed(() => Math.max(1, Math.round(natural.value.height * scale.value)))
29
+ const zoomLabel = computed(() => `${formatNumber(Math.round(scale.value * 100))}%`)
30
+ let resizeObserver: ResizeObserver | undefined
31
+ let pan: { x: number, y: number, left: number, top: number } | null = null
32
+ let dragged = false
33
+
34
+ watch(stage, (element) => {
35
+ resizeObserver?.disconnect()
36
+ if (!element) return
37
+ const measure = () => { viewport.value = { width: element.clientWidth, height: element.clientHeight } }
38
+ measure()
39
+ resizeObserver = new ResizeObserver(measure)
40
+ resizeObserver.observe(element)
41
+ }, { flush: 'post' })
42
+
43
+ function imageLoaded(event: Event) {
44
+ const image = event.target as HTMLImageElement
45
+ natural.value = { width: image.naturalWidth, height: image.naturalHeight }
46
+ }
47
+ async function setZoom(value: number) {
48
+ const element = stage.value
49
+ const centerX = element ? (element.scrollLeft + element.clientWidth / 2) / element.scrollWidth : 0.5
50
+ const centerY = element ? (element.scrollTop + element.clientHeight / 2) / element.scrollHeight : 0.5
51
+ zoom.value = Math.max(minScale.value, Math.min(8, value))
52
+ mode.value = 'manual'
53
+ await nextTick()
54
+ if (element) {
55
+ element.scrollLeft = centerX * element.scrollWidth - element.clientWidth / 2
56
+ element.scrollTop = centerY * element.scrollHeight - element.clientHeight / 2
57
+ }
58
+ }
59
+ function fitImage() {
60
+ mode.value = 'fit'
61
+ if (stage.value) { stage.value.scrollLeft = 0; stage.value.scrollTop = 0 }
62
+ }
63
+ function panStart(event: PointerEvent) {
64
+ if (event.pointerType !== 'mouse' || event.button !== 0 || !stage.value || scale.value <= fitScale.value) return
65
+ pan = { x: event.clientX, y: event.clientY, left: stage.value.scrollLeft, top: stage.value.scrollTop }
66
+ dragged = false
67
+ event.preventDefault()
68
+ }
69
+ function panMove(event: PointerEvent) {
70
+ if (!pan || !stage.value) return
71
+ const dx = event.clientX - pan.x, dy = event.clientY - pan.y
72
+ if (Math.abs(dx) + Math.abs(dy) > 5) {
73
+ dragged = true
74
+ if (!stage.value.hasPointerCapture(event.pointerId)) stage.value.setPointerCapture(event.pointerId)
75
+ }
76
+ stage.value.scrollLeft = pan.left - dx
77
+ stage.value.scrollTop = pan.top - dy
78
+ }
79
+ function panEnd() { pan = null }
80
+ watch(src, () => { natural.value = { width: 0, height: 0 }; fitImage() })
81
+
82
+ let returnFocus: HTMLElement | null = null
17
83
  let locked = false
18
84
  let touchX = 0
19
85
  let touchTracking = false
@@ -43,9 +109,21 @@ function close() {
43
109
 
44
110
  function onKeydown(event: KeyboardEvent) {
45
111
  if (!open.value) return
112
+ if (event.key === 'Tab') {
113
+ const dialog = closeButton.value?.closest('[role="dialog"]')
114
+ const buttons = dialog ? Array.from(dialog.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')) : []
115
+ const first = buttons[0], last = buttons.at(-1)
116
+ if (first && last && event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
117
+ else if (first && last && !event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
118
+ return
119
+ }
46
120
  const forward = dir.value === 'rtl' ? 'ArrowLeft' : 'ArrowRight'
47
121
  const back = dir.value === 'rtl' ? 'ArrowRight' : 'ArrowLeft'
48
- if (event.key === 'Escape') {
122
+ if (event.key === '+' || event.key === '=') { event.preventDefault(); void setZoom(scale.value * 1.25) }
123
+ else if (event.key === '-') { event.preventDefault(); void setZoom(scale.value / 1.25) }
124
+ else if (event.key === '0') { event.preventDefault(); void setZoom(1) }
125
+ else if (event.key.toLowerCase() === 'f') { event.preventDefault(); fitImage() }
126
+ else if (event.key === 'Escape') {
49
127
  event.preventDefault()
50
128
  close()
51
129
  }
@@ -60,7 +138,7 @@ function onKeydown(event: KeyboardEvent) {
60
138
  }
61
139
 
62
140
  function onTouchStart(event: TouchEvent) {
63
- if (event.touches.length !== 1) {
141
+ if (event.touches.length !== 1 || scale.value > fitScale.value * 1.01) {
64
142
  touchTracking = false
65
143
  return
66
144
  }
@@ -81,16 +159,19 @@ function onTouchEnd(event: TouchEvent) {
81
159
  }
82
160
 
83
161
  function onBackdropClick(event: MouseEvent) {
162
+ if (dragged) { dragged = false; return }
84
163
  if (swiped) {
85
164
  swiped = false
86
165
  return
87
166
  }
88
- if (event.target === event.currentTarget) close()
167
+ if (event.target === event.currentTarget || (event.target as HTMLElement).dataset.imageCanvas != null) close()
89
168
  }
90
169
 
91
170
  watch(open, (value) => {
92
171
  setLocked(value)
172
+ if (value) { returnFocus = document.activeElement as HTMLElement | null; natural.value = { width: 0, height: 0 }; fitImage() }
93
173
  if (value) void nextTick(() => closeButton.value?.focus())
174
+ else void nextTick(() => { if (returnFocus?.isConnected) returnFocus.focus() })
94
175
  })
95
176
 
96
177
  watch(() => props.images.length, (length) => {
@@ -105,6 +186,7 @@ watch(() => props.images.length, (length) => {
105
186
 
106
187
  onMounted(() => window.addEventListener('keydown', onKeydown))
107
188
  onBeforeUnmount(() => {
189
+ resizeObserver?.disconnect()
108
190
  window.removeEventListener('keydown', onKeydown)
109
191
  setLocked(false)
110
192
  })
@@ -112,60 +194,46 @@ onBeforeUnmount(() => {
112
194
 
113
195
  <template>
114
196
  <Teleport to="body">
115
- <div
116
- v-if="open && src"
117
- role="dialog"
118
- aria-modal="true"
119
- :aria-label="t('common.label.image')"
120
- class="fixed inset-0 z-[80] flex items-center justify-center bg-black/90 p-3 outline-none sm:p-6"
121
- @click="onBackdropClick"
122
- @touchstart.passive="onTouchStart"
123
- @touchend.passive="onTouchEnd"
124
- >
125
- <button
126
- ref="closeButton"
127
- type="button"
128
- class="absolute end-3 top-3 z-10 flex size-10 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80"
129
- :aria-label="t('common.action.close')"
130
- @click="close"
131
- >
132
- <UIcon name="i-lucide-x" class="size-5" />
133
- </button>
134
-
135
- <button
136
- v-if="multiple"
137
- type="button"
138
- class="absolute start-3 top-1/2 z-10 flex size-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80"
139
- :aria-label="t('common.action.prev')"
140
- @click="step(-1)"
141
- >
142
- <UIcon name="i-lucide-chevron-left" class="size-5 rtl:rotate-180" />
143
- </button>
144
-
145
- <img
146
- :key="src"
147
- :src="src"
148
- :alt="t('common.label.image')"
149
- class="max-h-full max-w-full object-contain select-none"
150
- draggable="false"
151
- >
152
-
153
- <button
154
- v-if="multiple"
155
- type="button"
156
- class="absolute end-3 top-1/2 z-10 flex size-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80"
157
- :aria-label="t('common.action.next')"
158
- @click="step(1)"
159
- >
160
- <UIcon name="i-lucide-chevron-right" class="size-5 rtl:rotate-180" />
161
- </button>
162
-
163
- <p
164
- v-if="multiple"
165
- class="pointer-events-none absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-3 py-1 text-sm tabular-nums text-white"
166
- >
167
- {{ index + 1 }} / {{ images.length }}
168
- </p>
197
+ <div v-if="open && src" role="dialog" aria-modal="true" :aria-label="t('common.label.image')"
198
+ class="fixed inset-0 z-[80] flex flex-col bg-black/90 text-white outline-none">
199
+ <div class="flex shrink-0 items-start gap-1 p-2">
200
+ <div class="flex min-w-0 flex-1 flex-wrap items-center justify-center gap-1" role="toolbar" :aria-label="t('common.label.imageControls')">
201
+ <UTooltip :text="t('common.action.zoomOut')" :ui="{ content: 'z-[90]' }">
202
+ <button type="button" :aria-label="t('common.action.zoomOut')" :disabled="!natural.width || scale <= minScale" class="flex size-10 items-center justify-center rounded-full hover:bg-white/15 disabled:opacity-30" @click="setZoom(scale / 1.25)"><UIcon name="i-lucide-minus" class="size-5" /></button>
203
+ </UTooltip>
204
+ <output class="min-w-14 text-center text-sm tabular-nums" aria-live="polite">{{ zoomLabel }}</output>
205
+ <UTooltip :text="t('common.action.zoomIn')" :ui="{ content: 'z-[90]' }">
206
+ <button type="button" :aria-label="t('common.action.zoomIn')" :disabled="!natural.width || scale >= 8" class="flex size-10 items-center justify-center rounded-full hover:bg-white/15 disabled:opacity-30" @click="setZoom(scale * 1.25)"><UIcon name="i-lucide-plus" class="size-5" /></button>
207
+ </UTooltip>
208
+ <UTooltip :text="t('common.action.fitImage')" :ui="{ content: 'z-[90]' }">
209
+ <button type="button" :aria-label="t('common.action.fitImage')" :aria-pressed="mode === 'fit'" class="flex size-10 items-center justify-center rounded-full hover:bg-white/15" @click="fitImage"><UIcon name="i-lucide-scan" class="size-5" /></button>
210
+ </UTooltip>
211
+ <UTooltip :text="t('common.action.originalImage')" :ui="{ content: 'z-[90]' }">
212
+ <button type="button" :aria-label="t('common.action.originalImage')" class="h-10 rounded-full px-3 text-sm hover:bg-white/15" @click="setZoom(1)">100%</button>
213
+ </UTooltip>
214
+ </div>
215
+ <UTooltip :text="t('common.action.close')" :ui="{ content: 'z-[90]' }">
216
+ <button ref="closeButton" type="button" class="flex size-10 shrink-0 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80" :aria-label="t('common.action.close')" @click="close"><UIcon name="i-lucide-x" class="size-5" /></button>
217
+ </UTooltip>
218
+ </div>
219
+ <div ref="stage" role="region" tabindex="0" :aria-label="t('common.label.image')" class="min-h-0 flex-1 overflow-auto overscroll-contain outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70" dir="ltr"
220
+ :class="scale > fitScale ? 'cursor-grab active:cursor-grabbing' : ''"
221
+ @click="onBackdropClick" @pointerdown="panStart" @pointermove="panMove" @pointerup="panEnd" @pointercancel="panEnd"
222
+ @touchstart.passive="onTouchStart" @touchend.passive="onTouchEnd">
223
+ <div data-image-canvas class="flex min-h-full min-w-full items-center justify-center p-4"
224
+ :style="{ width: `${imageWidth + 32}px`, height: `${imageHeight + 32}px` }">
225
+ <img :key="src" :src="src" :alt="t('common.label.image')" draggable="false"
226
+ class="shrink-0 select-none" :class="natural.width ? '' : 'invisible'"
227
+ :style="{ width: `${imageWidth}px`, height: `${imageHeight}px`, maxWidth: 'none', maxHeight: 'none' }" @load="imageLoaded">
228
+ </div>
229
+ </div>
230
+ <UTooltip v-if="multiple" :text="t('common.action.prev')" :ui="{ content: 'z-[90]' }">
231
+ <button type="button" class="absolute start-3 top-1/2 z-10 flex size-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80" :aria-label="t('common.action.prev')" @click="step(-1)"><UIcon name="i-lucide-chevron-left" class="size-5 rtl:rotate-180" /></button>
232
+ </UTooltip>
233
+ <UTooltip v-if="multiple" :text="t('common.action.next')" :ui="{ content: 'z-[90]' }">
234
+ <button type="button" class="absolute end-3 top-1/2 z-10 flex size-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80" :aria-label="t('common.action.next')" @click="step(1)"><UIcon name="i-lucide-chevron-right" class="size-5 rtl:rotate-180" /></button>
235
+ </UTooltip>
236
+ <p v-if="multiple" class="pointer-events-none absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-3 py-1 text-sm tabular-nums text-white">{{ index + 1 }} / {{ images.length }}</p>
169
237
  </div>
170
238
  </Teleport>
171
239
  </template>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { useMemiBoardAuth } from 'memi-board/runtime'
2
+ import { useMemiBoardI18n, useMemiBoardAuth } from 'memi-board/runtime'
3
3
  import { useMemiBoardLikes } from 'memi-board/runtime'
4
4
 
5
5
  const props = defineProps<{
@@ -8,21 +8,24 @@ const props = defineProps<{
8
8
  likeCount: number
9
9
  }>()
10
10
 
11
+ const { t } = useMemiBoardI18n()
11
12
  const { isSignedIn } = useMemiBoardAuth()
12
13
  const { isLiked, likePending, toggleLike } = useMemiBoardLikes(props.boardId, props.postId)
13
14
  </script>
14
15
 
15
16
  <template>
16
- <UButton
17
- v-if="isSignedIn"
18
- icon="i-lucide-heart"
19
- size="sm"
20
- variant="ghost"
21
- :color="isLiked ? 'error' : 'neutral'"
22
- :label="String(likeCount ?? 0)"
23
- :loading="likePending"
24
- @click="toggleLike"
25
- />
17
+ <UTooltip v-if="isSignedIn" :text="isLiked ? t('common.action.unlike') : t('common.action.like')" :delay-duration="200">
18
+ <UButton
19
+ icon="i-lucide-heart"
20
+ size="sm"
21
+ variant="ghost"
22
+ :color="isLiked ? 'error' : 'neutral'"
23
+ :label="String(likeCount ?? 0)"
24
+ :loading="likePending"
25
+ :aria-label="isLiked ? t('common.action.unlike') : t('common.action.like')"
26
+ @click="toggleLike"
27
+ />
28
+ </UTooltip>
26
29
  <span
27
30
  v-else
28
31
  class="flex items-center gap-1 text-sm text-muted"
@@ -21,15 +21,15 @@ const options = computed<Array<{ label: string, value: BoardListView, icon: stri
21
21
 
22
22
  <template>
23
23
  <UFieldGroup>
24
- <UButton
25
- v-for="option in options"
26
- :key="option.value"
27
- :icon="option.icon"
28
- :aria-label="option.label"
29
- size="sm"
30
- :color="modelValue === option.value ? 'primary' : 'neutral'"
31
- :variant="modelValue === option.value ? 'solid' : 'outline'"
32
- @click="emit('update:modelValue', option.value)"
33
- />
24
+ <UTooltip v-for="option in options" :key="option.value" :text="option.label" :delay-duration="200">
25
+ <UButton
26
+ :icon="option.icon"
27
+ :aria-label="option.label"
28
+ size="sm"
29
+ :color="modelValue === option.value ? 'primary' : 'neutral'"
30
+ :variant="modelValue === option.value ? 'solid' : 'outline'"
31
+ @click="emit('update:modelValue', option.value)"
32
+ />
33
+ </UTooltip>
34
34
  </UFieldGroup>
35
35
  </template>
@@ -6,6 +6,8 @@ import MemiBoardProfileEditModal from './ProfileEditModal.vue'
6
6
 
7
7
  const props = defineProps<{
8
8
  uid: string
9
+ /** 본인 프로필의 수정 버튼 대신 표시할 호스트 계정 페이지 주소. */
10
+ accountTo?: string
9
11
  }>()
10
12
 
11
13
  const { profile, profilePending } = useMemiBoardUserProfile(() => props.uid)
@@ -84,7 +86,17 @@ const aiUsage = computed(() => {
84
86
  </p>
85
87
  </div>
86
88
  <UButton
87
- v-if="isOwnProfile"
89
+ v-if="isOwnProfile && accountTo"
90
+ :to="accountTo"
91
+ :label="t('profile.myAccount')"
92
+ icon="i-lucide-user-round"
93
+ color="neutral"
94
+ variant="outline"
95
+ size="sm"
96
+ class="shrink-0"
97
+ />
98
+ <UButton
99
+ v-else-if="isOwnProfile"
88
100
  :label="t('common.action.edit')"
89
101
  icon="i-lucide-pencil"
90
102
  color="neutral"
@@ -125,7 +137,7 @@ const aiUsage = computed(() => {
125
137
  </div>
126
138
 
127
139
  <MemiBoardProfileEditModal
128
- v-if="isOwnProfile"
140
+ v-if="isOwnProfile && !accountTo"
129
141
  v-model:open="editOpen"
130
142
  :uid="uid"
131
143
  :profile="profile"
@@ -2,7 +2,7 @@
2
2
  import { computed, ref, watch } from 'vue'
3
3
  import { query, serverTimestamp, where, writeBatch, getDocs } from 'firebase/firestore'
4
4
  import { useFirestore } from 'vuefire'
5
- import { boardUserDoc, commentsCol, postsCol, useBoardPathConfig, useMemiBoardI18n } from 'memi-board/runtime'
5
+ import { boardUserDoc, commentsCol, postsCol, useBoardPathConfig, useMemiBoardI18n, useMemiBoardModeration } from 'memi-board/runtime'
6
6
  import type { BoardUserModel } from 'memi-board/runtime'
7
7
  import { useMemiBoardStorage } from 'memi-board/storage'
8
8
  import MemiBoardAvatarCropModal from './AvatarCropModal.vue'
@@ -18,11 +18,13 @@ const emit = defineEmits<{ saved: [] }>()
18
18
 
19
19
  const db = useFirestore()
20
20
  const { t } = useMemiBoardI18n()
21
+ const { checkText: checkName } = useMemiBoardModeration({ context: 'profile-name' })
21
22
 
22
23
  const displayName = ref('')
23
24
  const bio = ref('')
24
25
  const photoURL = ref<string | null>(null)
25
26
  const saving = ref(false)
27
+ const checkingName = ref(false)
26
28
  const error = ref('')
27
29
 
28
30
  // 열릴 때마다 최신 프로필로 폼을 리셋 — 이전에 취소한 입력이 남아있지 않게.
@@ -128,6 +130,15 @@ async function onSave() {
128
130
  saving.value = true
129
131
  error.value = ''
130
132
  try {
133
+ if (trimmedName !== (props.profile?.displayName ?? '').trim()) {
134
+ checkingName.value = true
135
+ const review = await checkName(trimmedName)
136
+ checkingName.value = false
137
+ if (review.flagged || review.error) {
138
+ error.value = review.reason || t('profileEditModal.nameRejected')
139
+ return
140
+ }
141
+ }
131
142
  let nextPhotoURL = props.profile?.photoURL ?? null
132
143
  if (pendingAvatarFile) {
133
144
  avatarUploading.value = true
@@ -146,6 +157,7 @@ async function onSave() {
146
157
  }
147
158
  finally {
148
159
  saving.value = false
160
+ checkingName.value = false
149
161
  avatarUploading.value = false
150
162
  }
151
163
  }
@@ -155,6 +167,8 @@ async function onSave() {
155
167
  <UModal
156
168
  v-model:open="open"
157
169
  :title="t('profileEditModal.title')"
170
+ :dismissible="!saving"
171
+ :close="!saving"
158
172
  :ui="{ content: 'sm:max-w-md' }"
159
173
  >
160
174
  <template #body>
@@ -172,6 +186,7 @@ async function onSave() {
172
186
  variant="outline"
173
187
  size="sm"
174
188
  @click="avatarInput?.click()"
189
+ :disabled="saving"
175
190
  />
176
191
  <input
177
192
  ref="avatarInput"
@@ -182,11 +197,12 @@ async function onSave() {
182
197
  >
183
198
  </div>
184
199
 
185
- <UFormField :label="t('profileEditModal.name')">
200
+ <UFormField :label="t('profileEditModal.name')" :help="t('profileEditModal.nameReviewHelp')">
186
201
  <UInput
187
202
  v-model="displayName"
188
203
  :placeholder="t('profileEditModal.name')"
189
204
  :maxlength="40"
205
+ :disabled="saving"
190
206
  class="w-full"
191
207
  />
192
208
  </UFormField>
@@ -196,6 +212,7 @@ async function onSave() {
196
212
  v-model="bio"
197
213
  :rows="3"
198
214
  :maxlength="200"
215
+ :disabled="saving"
199
216
  :placeholder="t('profileEditModal.bioPlaceholder')"
200
217
  class="w-full"
201
218
  />
@@ -224,7 +241,7 @@ async function onSave() {
224
241
  :loading="saving"
225
242
  @click="onSave"
226
243
  >
227
- {{ t('common.action.save') }}
244
+ {{ checkingName ? t('profileEditModal.checkingName') : t('common.action.save') }}
228
245
  </UButton>
229
246
  </div>
230
247
  </template>
@@ -20,11 +20,11 @@ const props = defineProps<{
20
20
  }>()
21
21
 
22
22
  const { isSignedIn, user } = useMemiBoardAuth()
23
- const { t, locale, formatNumber } = useMemiBoardI18n()
23
+ const { t, locale, formatNumber, formatRelativeDate } = useMemiBoardI18n()
24
24
  const reasonItems = computed(() =>
25
25
  REPORT_REASONS.map(item => ({ ...item, label: reportReasonLabel(item.value, locale.value) })),
26
26
  )
27
- const { hasReported, pending, submitReport } = useMemiBoardReports(props.boardId, props.postId)
27
+ const { hasReported, report, reportPending, reportError, pending, submitReport } = useMemiBoardReports(props.boardId, props.postId)
28
28
 
29
29
  const open = ref(false)
30
30
  const reason = ref<BoardReportReason>('spam')
@@ -34,9 +34,10 @@ const done = ref(false)
34
34
 
35
35
  const isOwnPost = computed(() => !!user.value && user.value.uid === props.authorUid)
36
36
  const canReport = computed(() => isSignedIn.value && !isOwnPost.value)
37
+ const statusLabel = computed(() => t(`reportButton.status.${report.value?.status ?? 'open'}`))
37
38
 
38
39
  function openModal() {
39
- if (hasReported.value || pending.value) return
40
+ if (reportPending.value || reportError.value || pending.value) return
40
41
  reason.value = 'spam'
41
42
  detail.value = ''
42
43
  error.value = ''
@@ -70,15 +71,26 @@ async function submit() {
70
71
  size="sm"
71
72
  variant="ghost"
72
73
  :color="hasReported ? 'warning' : 'neutral'"
73
- :label="hasReported ? t('reportButton.reported') : t('common.action.report')"
74
- :disabled="hasReported"
74
+ :label="hasReported ? statusLabel : t('common.action.report')"
75
+ :disabled="reportPending || !!reportError || pending"
75
76
  @click="openModal"
76
77
  />
77
- <UModal v-model:open="open" :title="t('reportButton.title')">
78
+ <p v-if="reportError" role="alert" class="text-xs text-error">{{ reportError }}</p>
79
+ <UModal v-model:open="open" :title="hasReported && !done ? t('reportButton.resultTitle') : t('reportButton.title')">
78
80
  <template #body>
79
81
  <div v-if="done" class="py-4 text-sm text-muted">
80
82
  {{ t('reportButton.done') }}
81
83
  </div>
84
+ <div v-else-if="hasReported && report" class="space-y-3 text-sm" role="status">
85
+ <p class="font-semibold text-highlighted">{{ statusLabel }}</p>
86
+ <p class="text-muted">{{ t(`reportButton.result.${report.status}`) }}</p>
87
+ <p v-if="report.reviewedAt" class="text-xs text-muted">{{ t('reportButton.reviewedAt') }}: {{ formatRelativeDate(report.reviewedAt, Date.now()) }}</p>
88
+ <div v-if="report.status !== 'open'" class="rounded-lg bg-muted p-3">
89
+ <p class="font-medium text-highlighted">{{ t('reportButton.reviewNote') }}</p>
90
+ <p class="mt-1 whitespace-pre-wrap break-words text-toned">{{ report.reviewNote || t('reportButton.noNote') }}</p>
91
+ </div>
92
+ <UButton color="neutral" variant="outline" :label="t('common.action.close')" @click="open = false" />
93
+ </div>
82
94
  <form
83
95
  v-else
84
96
  class="flex flex-col gap-3"
@@ -90,16 +102,13 @@ async function submit() {
90
102
  value-key="value"
91
103
  :legend="t('reportButton.reason')"
92
104
  />
93
- <UTextarea
94
- v-model="detail"
95
- :rows="3"
96
- :maxlength="REPORT_DETAIL_MAX_LENGTH"
97
- :placeholder="t('reportButton.detailPlaceholder')"
98
- />
105
+ <UFormField :label="reason === 'other' ? t('reportButton.detailRequired') : t('reportButton.detailPlaceholder')" :required="reason === 'other'">
106
+ <UTextarea v-model="detail" :rows="3" :maxlength="REPORT_DETAIL_MAX_LENGTH" :required="reason === 'other'" class="w-full" />
107
+ </UFormField>
99
108
  <p class="text-right text-xs text-dimmed">
100
109
  {{ formatNumber(detail.length) }} / {{ formatNumber(REPORT_DETAIL_MAX_LENGTH) }}
101
110
  </p>
102
- <p v-if="error" class="text-xs text-error">
111
+ <p v-if="error" role="alert" class="text-xs text-error">
103
112
  {{ error }}
104
113
  </p>
105
114
  <div class="flex justify-end gap-2">