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.
- package/README.md +55 -9
- package/dist/{ar-CQhBdHu3.js → ar-GNguZBN4.js} +45 -4
- package/dist/ar-GNguZBN4.js.map +1 -0
- package/dist/{commentImages-DsW2yCqu.js → commentImages-CRK_2uVi.js} +195 -70
- package/dist/commentImages-CRK_2uVi.js.map +1 -0
- package/dist/composables/useMemiBoardModeration.d.ts +2 -0
- package/dist/composables/useMemiBoardPosts.d.ts +1 -1
- package/dist/composables/useMemiBoardReports.d.ts +102 -3
- package/dist/composables/useMemiBoardUserDirectory.d.ts +20 -0
- package/dist/composables/useMemiBoardUserProfile.d.ts +1 -1
- package/dist/composables/useMemiBoardUsers.d.ts +1 -1
- package/dist/{de-B4GZqxQj.js → de-CS0iRLqe.js} +45 -4
- package/dist/de-CS0iRLqe.js.map +1 -0
- package/dist/{en-BDkfivhc.js → en-DeExi-DR.js} +129 -4
- package/dist/en-DeExi-DR.js.map +1 -0
- package/dist/{es-DzFDaLwo.js → es-dlEFrox-.js} +45 -4
- package/dist/es-dlEFrox-.js.map +1 -0
- package/dist/{fr-Zf5mcs8m.js → fr-B9sjKJF4.js} +45 -4
- package/dist/fr-B9sjKJF4.js.map +1 -0
- package/dist/{id-CaWN8212.js → id-Ca7skouh.js} +45 -4
- package/dist/id-Ca7skouh.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1089 -829
- package/dist/index.js.map +1 -1
- package/dist/{ja-xDttH9CE.js → ja-ByVLGIDd.js} +45 -4
- package/dist/ja-ByVLGIDd.js.map +1 -0
- package/dist/module.mjs +4 -0
- package/dist/module.mjs.map +1 -1
- package/dist/{pt-WIfxYYWQ.js → pt-COQL_Q7A.js} +45 -4
- package/dist/pt-COQL_Q7A.js.map +1 -0
- package/dist/runtime/components/AccountDeletion.vue +64 -0
- package/dist/runtime/components/AuthorMenu.vue +4 -4
- package/dist/runtime/components/CommentFeed.vue +17 -13
- package/dist/runtime/components/CommentForm.vue +58 -56
- package/dist/runtime/components/CommentImageField.vue +18 -13
- package/dist/runtime/components/CommentItem.vue +95 -60
- package/dist/runtime/components/CommentList.vue +10 -1
- package/dist/runtime/components/CommentThread.vue +16 -2
- package/dist/runtime/components/CustomFieldEditor.vue +10 -8
- package/dist/runtime/components/Detail.vue +18 -10
- package/dist/runtime/components/Editor.vue +1 -5
- package/dist/runtime/components/ImageLightbox.vue +126 -58
- package/dist/runtime/components/LikeButton.vue +14 -11
- package/dist/runtime/components/ListViewSwitch.vue +10 -10
- package/dist/runtime/components/Profile.vue +14 -2
- package/dist/runtime/components/ProfileEditModal.vue +20 -3
- package/dist/runtime/components/ReportButton.vue +22 -13
- package/dist/runtime/components/ReportList.vue +77 -93
- package/dist/runtime/components/UserListControls.vue +29 -0
- package/dist/runtime/components/UserListPagination.vue +17 -0
- package/dist/runtime/components/UserManagementEmail.vue +21 -0
- package/dist/runtime/components/UserRoleGuide.vue +38 -0
- package/dist/runtime/components/Users.vue +21 -59
- package/dist/runtime/components/UsersSettings.vue +119 -0
- package/dist/storage.js +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/utils/postSlug.d.ts +12 -0
- package/dist/utils/profileNameModeration.d.ts +8 -0
- package/dist/utils/userDirectoryPage.d.ts +19 -0
- package/dist/{zh-CI6Sd8PD.js → zh-DBjX9qAS.js} +45 -4
- package/dist/zh-DBjX9qAS.js.map +1 -0
- package/docs/account-deletion.md +60 -0
- package/docs/firebase-setup.md +77 -0
- package/docs/firestore.rules.example +78 -4
- package/docs/host-integration.md +85 -11
- package/docs/storage.rules.example +7 -2
- package/package.json +3 -2
- package/scripts/backfill-post-slugs.mjs +38 -0
- package/dist/ar-CQhBdHu3.js.map +0 -1
- package/dist/commentImages-DsW2yCqu.js.map +0 -1
- package/dist/de-B4GZqxQj.js.map +0 -1
- package/dist/en-BDkfivhc.js.map +0 -1
- package/dist/es-DzFDaLwo.js.map +0 -1
- package/dist/fr-Zf5mcs8m.js.map +0 -1
- package/dist/id-CaWN8212.js.map +0 -1
- package/dist/ja-xDttH9CE.js.map +0 -1
- package/dist/pt-WIfxYYWQ.js.map +0 -1
- 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 === '
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
aria-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
class="
|
|
139
|
-
|
|
140
|
-
@
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
class="
|
|
150
|
-
|
|
151
|
-
>
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 (
|
|
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 ?
|
|
74
|
-
:disabled="
|
|
74
|
+
:label="hasReported ? statusLabel : t('common.action.report')"
|
|
75
|
+
:disabled="reportPending || !!reportError || pending"
|
|
75
76
|
@click="openModal"
|
|
76
77
|
/>
|
|
77
|
-
<
|
|
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
|
-
<
|
|
94
|
-
v-model="detail"
|
|
95
|
-
|
|
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">
|