memi-board 0.5.0 → 0.9.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 +11 -8
- package/dist/composables/useMemiBoardAuth.d.ts +2 -0
- package/dist/composables/useMemiBoardComments.d.ts +81 -4
- package/dist/composables/useMemiBoardUsers.d.ts +11 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +501 -265
- package/dist/index.js.map +1 -1
- package/dist/module.mjs +4 -0
- package/dist/module.mjs.map +1 -1
- package/dist/runtime/components/CommentForm.vue +21 -6
- package/dist/runtime/components/CommentItem.vue +73 -0
- package/dist/runtime/components/CommentList.vue +83 -39
- package/dist/runtime/components/CommentSkeleton.vue +12 -0
- package/dist/runtime/components/CommentThread.vue +121 -0
- package/dist/runtime/components/Detail.vue +3 -6
- package/dist/runtime/components/Settings.vue +24 -4
- package/dist/runtime/components/Users.vue +64 -0
- package/dist/types.d.ts +23 -2
- package/docs/firestore.rules.example +88 -14
- package/docs/storage.rules.example +6 -6
- package/package.json +3 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import dayjs from 'dayjs'
|
|
4
|
+
import relativeTime from 'dayjs/plugin/relativeTime'
|
|
5
|
+
import 'dayjs/locale/ko'
|
|
6
|
+
import type { CommentModel } from 'memi-board'
|
|
7
|
+
import { useMemiBoardAuth } from 'memi-board'
|
|
8
|
+
|
|
9
|
+
dayjs.extend(relativeTime)
|
|
10
|
+
dayjs.locale('ko')
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
comment: CommentModel
|
|
14
|
+
now: number
|
|
15
|
+
deleting?: boolean
|
|
16
|
+
}>()
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{
|
|
19
|
+
delete: [commentId: string]
|
|
20
|
+
reply: [comment: CommentModel]
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
const { canDeleteComment } = useMemiBoardAuth()
|
|
24
|
+
|
|
25
|
+
const date = computed(() => props.comment.createdAt?.toDate?.())
|
|
26
|
+
const relativeDate = computed(() => date.value ? dayjs(date.value).from(dayjs(props.now)) : '방금 전')
|
|
27
|
+
const fullDate = computed(() => date.value ? dayjs(date.value).format('YYYY년 M월 D일 HH:mm:ss') : '시간 확인 중')
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<div class="flex items-start gap-3">
|
|
32
|
+
<UAvatar
|
|
33
|
+
:src="comment.authorPhoto ?? undefined"
|
|
34
|
+
:alt="comment.authorName ?? '익명'"
|
|
35
|
+
size="sm"
|
|
36
|
+
/>
|
|
37
|
+
<div class="min-w-0 flex-1">
|
|
38
|
+
<div class="flex items-center gap-2">
|
|
39
|
+
<span class="text-sm font-medium">{{ comment.authorName ?? '익명' }}</span>
|
|
40
|
+
<UTooltip :text="fullDate">
|
|
41
|
+
<time
|
|
42
|
+
:datetime="date?.toISOString()"
|
|
43
|
+
class="cursor-help text-xs text-muted"
|
|
44
|
+
>
|
|
45
|
+
{{ relativeDate }}
|
|
46
|
+
</time>
|
|
47
|
+
</UTooltip>
|
|
48
|
+
</div>
|
|
49
|
+
<p class="whitespace-pre-wrap break-words text-sm">
|
|
50
|
+
<span v-if="comment.parentId && comment.replyToName" class="mr-1 text-primary">@{{ comment.replyToName }}</span>
|
|
51
|
+
{{ comment.body }}
|
|
52
|
+
</p>
|
|
53
|
+
<UButton
|
|
54
|
+
label="답글"
|
|
55
|
+
size="xs"
|
|
56
|
+
color="neutral"
|
|
57
|
+
variant="link"
|
|
58
|
+
class="mt-1 px-0"
|
|
59
|
+
@click="emit('reply', comment)"
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
<UButton
|
|
63
|
+
v-if="canDeleteComment(comment) && comment.id"
|
|
64
|
+
icon="i-lucide-trash-2"
|
|
65
|
+
size="xs"
|
|
66
|
+
variant="ghost"
|
|
67
|
+
color="error"
|
|
68
|
+
:loading="deleting"
|
|
69
|
+
aria-label="댓글 삭제"
|
|
70
|
+
@click="emit('delete', comment.id)"
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|
|
@@ -1,65 +1,109 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref } from 'vue'
|
|
3
|
-
import { formatDate } from 'memi-board'
|
|
2
|
+
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
4
3
|
import { useMemiBoardComments } from 'memi-board'
|
|
5
|
-
import
|
|
4
|
+
import MemiBoardCommentSkeleton from './CommentSkeleton.vue'
|
|
5
|
+
import MemiBoardCommentThread from './CommentThread.vue'
|
|
6
6
|
|
|
7
7
|
const props = defineProps<{ postId: string }>()
|
|
8
8
|
|
|
9
|
-
const { comments,
|
|
10
|
-
const
|
|
9
|
+
const { comments, commentsPending, hasMore, loadingMore, loadMore } = useMemiBoardComments(props.postId)
|
|
10
|
+
const now = ref(Date.now())
|
|
11
|
+
const loadMoreTrigger = ref<HTMLElement | null>(null)
|
|
12
|
+
let relativeTimeTimer: ReturnType<typeof setInterval> | undefined
|
|
13
|
+
let loadMoreDelayTimer: ReturnType<typeof setTimeout> | undefined
|
|
14
|
+
let loadMoreObserver: IntersectionObserver | undefined
|
|
15
|
+
const waitingToLoad = ref(false)
|
|
16
|
+
const morePending = computed(() => waitingToLoad.value || loadingMore.value)
|
|
17
|
+
let triggerVisible = false
|
|
18
|
+
let automaticRequest = false
|
|
11
19
|
|
|
12
|
-
|
|
20
|
+
function cancelAutomaticLoad() {
|
|
21
|
+
if (!automaticRequest || !loadMoreDelayTimer) return
|
|
22
|
+
clearTimeout(loadMoreDelayTimer)
|
|
23
|
+
loadMoreDelayTimer = undefined
|
|
24
|
+
automaticRequest = false
|
|
25
|
+
waitingToLoad.value = false
|
|
26
|
+
}
|
|
13
27
|
|
|
14
|
-
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
28
|
+
function scheduleLoadMore(automatic = false) {
|
|
29
|
+
if (loadMoreDelayTimer || loadingMore.value || !hasMore.value) return
|
|
30
|
+
automaticRequest = automatic
|
|
31
|
+
waitingToLoad.value = true
|
|
32
|
+
loadMoreDelayTimer = setTimeout(() => {
|
|
33
|
+
loadMoreDelayTimer = undefined
|
|
34
|
+
if (automaticRequest && !triggerVisible) {
|
|
35
|
+
automaticRequest = false
|
|
36
|
+
waitingToLoad.value = false
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
automaticRequest = false
|
|
40
|
+
waitingToLoad.value = false
|
|
41
|
+
if (hasMore.value && !loadingMore.value) void loadMore()
|
|
42
|
+
}, 1_000)
|
|
23
43
|
}
|
|
44
|
+
|
|
45
|
+
onMounted(() => {
|
|
46
|
+
relativeTimeTimer = setInterval(() => { now.value = Date.now() }, 60_000)
|
|
47
|
+
loadMoreObserver = new IntersectionObserver((entries) => {
|
|
48
|
+
triggerVisible = entries.some(entry => entry.isIntersecting)
|
|
49
|
+
if (triggerVisible) scheduleLoadMore(true)
|
|
50
|
+
else cancelAutomaticLoad()
|
|
51
|
+
})
|
|
52
|
+
if (loadMoreTrigger.value) loadMoreObserver.observe(loadMoreTrigger.value)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
watch(loadMoreTrigger, (element, previous) => {
|
|
56
|
+
if (previous) loadMoreObserver?.unobserve(previous)
|
|
57
|
+
if (element) loadMoreObserver?.observe(element)
|
|
58
|
+
}, { flush: 'post' })
|
|
59
|
+
|
|
60
|
+
onUnmounted(() => {
|
|
61
|
+
clearInterval(relativeTimeTimer)
|
|
62
|
+
clearTimeout(loadMoreDelayTimer)
|
|
63
|
+
waitingToLoad.value = false
|
|
64
|
+
loadMoreObserver?.disconnect()
|
|
65
|
+
})
|
|
66
|
+
|
|
24
67
|
</script>
|
|
25
68
|
|
|
26
69
|
<template>
|
|
27
70
|
<div class="flex flex-col gap-3">
|
|
71
|
+
<div v-if="commentsPending" class="flex flex-col gap-3" aria-label="댓글을 불러오고 있습니다">
|
|
72
|
+
<MemiBoardCommentSkeleton v-for="index in 10" :key="`initial-${index}`" />
|
|
73
|
+
</div>
|
|
28
74
|
<p
|
|
29
|
-
v-if="!comments.length"
|
|
75
|
+
v-else-if="!comments.length"
|
|
30
76
|
class="text-sm text-muted"
|
|
31
77
|
>
|
|
32
78
|
아직 댓글이 없습니다.
|
|
33
79
|
</p>
|
|
34
80
|
|
|
35
|
-
<
|
|
81
|
+
<MemiBoardCommentThread
|
|
36
82
|
v-for="comment in comments"
|
|
37
83
|
:key="comment.id"
|
|
38
|
-
|
|
84
|
+
:root="comment"
|
|
85
|
+
:post-id="postId"
|
|
86
|
+
:now="now"
|
|
87
|
+
/>
|
|
88
|
+
|
|
89
|
+
<div
|
|
90
|
+
v-if="!commentsPending && hasMore"
|
|
91
|
+
ref="loadMoreTrigger"
|
|
92
|
+
class="flex justify-center py-3"
|
|
39
93
|
>
|
|
40
|
-
<UAvatar
|
|
41
|
-
:src="comment.authorPhoto ?? undefined"
|
|
42
|
-
:alt="comment.authorName ?? '익명'"
|
|
43
|
-
size="sm"
|
|
44
|
-
/>
|
|
45
|
-
<div class="flex-1 min-w-0">
|
|
46
|
-
<div class="flex items-center gap-2">
|
|
47
|
-
<span class="text-sm font-medium">{{ comment.authorName ?? '익명' }}</span>
|
|
48
|
-
<span class="text-xs text-muted">{{ formatDate(comment.createdAt) }}</span>
|
|
49
|
-
</div>
|
|
50
|
-
<p class="text-sm whitespace-pre-wrap break-words">
|
|
51
|
-
{{ comment.body }}
|
|
52
|
-
</p>
|
|
53
|
-
</div>
|
|
54
94
|
<UButton
|
|
55
|
-
|
|
56
|
-
icon="i-lucide-
|
|
57
|
-
|
|
58
|
-
variant="
|
|
59
|
-
|
|
60
|
-
:
|
|
61
|
-
@click="
|
|
95
|
+
label="댓글 더보기"
|
|
96
|
+
icon="i-lucide-chevron-down"
|
|
97
|
+
color="neutral"
|
|
98
|
+
variant="soft"
|
|
99
|
+
:loading="morePending"
|
|
100
|
+
:disabled="morePending"
|
|
101
|
+
@click="scheduleLoadMore()"
|
|
62
102
|
/>
|
|
63
103
|
</div>
|
|
104
|
+
|
|
105
|
+
<div v-if="morePending" class="flex flex-col gap-3" aria-label="댓글을 더 불러오고 있습니다">
|
|
106
|
+
<MemiBoardCommentSkeleton v-for="index in 10" :key="`more-${index}`" />
|
|
107
|
+
</div>
|
|
64
108
|
</div>
|
|
65
109
|
</template>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="flex items-start gap-3" aria-hidden="true">
|
|
3
|
+
<USkeleton class="size-8 shrink-0 rounded-full" />
|
|
4
|
+
<div class="flex-1 space-y-2 pt-0.5">
|
|
5
|
+
<div class="flex gap-2">
|
|
6
|
+
<USkeleton class="h-3.5 w-20" />
|
|
7
|
+
<USkeleton class="h-3.5 w-14" />
|
|
8
|
+
</div>
|
|
9
|
+
<USkeleton class="h-4 w-full max-w-md" />
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
</template>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import type { CommentModel } from 'memi-board'
|
|
4
|
+
import { useMemiBoardComments, useMemiBoardReplies } from 'memi-board'
|
|
5
|
+
import MemiBoardCommentForm from './CommentForm.vue'
|
|
6
|
+
import MemiBoardCommentItem from './CommentItem.vue'
|
|
7
|
+
import MemiBoardCommentSkeleton from './CommentSkeleton.vue'
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{ postId: string, root: CommentModel, now: number }>()
|
|
10
|
+
|
|
11
|
+
const { deleteComment } = useMemiBoardComments(props.postId, { subscribe: false })
|
|
12
|
+
const { replies, loading, loaded, hasMore, loadMore, refresh } = useMemiBoardReplies(props.postId, props.root.id!)
|
|
13
|
+
const expanded = ref((props.root.replyCount ?? 0) > 0)
|
|
14
|
+
const replyingTo = ref<CommentModel | null>(null)
|
|
15
|
+
const deletingId = ref<string | null>(null)
|
|
16
|
+
const error = ref('')
|
|
17
|
+
const needsMoreReplies = computed(() =>
|
|
18
|
+
hasMore.value
|
|
19
|
+
&& loaded.value
|
|
20
|
+
&& replies.value.length < (props.root.replyCount ?? Number.POSITIVE_INFINITY),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
onMounted(() => {
|
|
24
|
+
if (expanded.value && !loaded.value) void loadMore()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
watch(() => props.root.replyCount, (count) => {
|
|
28
|
+
if ((count ?? 0) <= 0) return
|
|
29
|
+
expanded.value = true
|
|
30
|
+
if (!loaded.value) void loadMore()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
async function toggleReplies() {
|
|
34
|
+
expanded.value = !expanded.value
|
|
35
|
+
if (expanded.value && !loaded.value) await loadMore()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function startReply(comment: CommentModel) {
|
|
39
|
+
replyingTo.value = comment
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function handleSaved() {
|
|
43
|
+
expanded.value = true
|
|
44
|
+
replyingTo.value = null
|
|
45
|
+
// 이미 펼쳐진 스레드는 onSnapshot이 새 답글을 누적한다. refresh()하면
|
|
46
|
+
// 작성 브라우저만 첫 5개 상태로 되돌아가므로 최초 조회 전일 때만 읽는다.
|
|
47
|
+
if (!loaded.value) await refresh()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function handleDelete(comment: CommentModel) {
|
|
51
|
+
if (!window.confirm('이 댓글을 삭제하시겠습니까?')) return
|
|
52
|
+
deletingId.value = comment.id ?? null
|
|
53
|
+
error.value = ''
|
|
54
|
+
try {
|
|
55
|
+
await deleteComment(comment)
|
|
56
|
+
if (comment.parentId) await refresh()
|
|
57
|
+
}
|
|
58
|
+
catch (cause) {
|
|
59
|
+
error.value = cause instanceof Error ? cause.message : '댓글을 삭제하지 못했습니다.'
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
deletingId.value = null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<template>
|
|
68
|
+
<article class="flex flex-col gap-3">
|
|
69
|
+
<MemiBoardCommentItem
|
|
70
|
+
:comment="root"
|
|
71
|
+
:now="now"
|
|
72
|
+
:deleting="deletingId === root.id"
|
|
73
|
+
@reply="startReply"
|
|
74
|
+
@delete="handleDelete(root)"
|
|
75
|
+
/>
|
|
76
|
+
|
|
77
|
+
<UButton
|
|
78
|
+
v-if="(root.replyCount ?? 0) > 0"
|
|
79
|
+
:label="expanded ? '답글 숨기기' : `답글 ${root.replyCount}개`"
|
|
80
|
+
:icon="expanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
81
|
+
color="neutral"
|
|
82
|
+
variant="link"
|
|
83
|
+
size="sm"
|
|
84
|
+
class="w-fit px-0"
|
|
85
|
+
@click="toggleReplies"
|
|
86
|
+
/>
|
|
87
|
+
|
|
88
|
+
<div v-if="expanded" class="flex flex-col gap-3 border-l border-default pl-4 sm:pl-6">
|
|
89
|
+
<MemiBoardCommentSkeleton v-for="index in (loading && !loaded) ? 5 : 0" :key="index" />
|
|
90
|
+
<MemiBoardCommentItem
|
|
91
|
+
v-for="reply in replies"
|
|
92
|
+
:key="reply.id"
|
|
93
|
+
:comment="reply"
|
|
94
|
+
:now="now"
|
|
95
|
+
:deleting="deletingId === reply.id"
|
|
96
|
+
:class="(reply.depth ?? 1) >= 2 ? 'ml-4 sm:ml-8' : ''"
|
|
97
|
+
@reply="startReply"
|
|
98
|
+
@delete="handleDelete(reply)"
|
|
99
|
+
/>
|
|
100
|
+
<UButton
|
|
101
|
+
v-if="needsMoreReplies"
|
|
102
|
+
label="답글 더보기"
|
|
103
|
+
color="neutral"
|
|
104
|
+
variant="soft"
|
|
105
|
+
size="sm"
|
|
106
|
+
:loading="loading"
|
|
107
|
+
@click="loadMore"
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<MemiBoardCommentForm
|
|
112
|
+
v-if="replyingTo"
|
|
113
|
+
:post-id="postId"
|
|
114
|
+
:parent="replyingTo"
|
|
115
|
+
class="ml-10"
|
|
116
|
+
@saved="handleSaved"
|
|
117
|
+
@cancel="replyingTo = null"
|
|
118
|
+
/>
|
|
119
|
+
<p v-if="error" class="text-xs text-error">{{ error }}</p>
|
|
120
|
+
</article>
|
|
121
|
+
</template>
|
|
@@ -6,7 +6,6 @@ import { formatDate, renderMarkdownToHtml } from 'memi-board'
|
|
|
6
6
|
import { useMemiBoardPosts } from 'memi-board'
|
|
7
7
|
import { useMemiBoardAuth } from 'memi-board'
|
|
8
8
|
import { useMemiBoardSettings } from 'memi-board'
|
|
9
|
-
import { useMemiBoardComments } from 'memi-board'
|
|
10
9
|
import MemiBoardAttachments from './Attachments.vue'
|
|
11
10
|
import MemiBoardCommentForm from './CommentForm.vue'
|
|
12
11
|
import MemiBoardCommentList from './CommentList.vue'
|
|
@@ -23,8 +22,6 @@ const emit = defineEmits<{
|
|
|
23
22
|
const { getPost, getAdjacentPosts, deletePost } = useMemiBoardPosts()
|
|
24
23
|
const { canEdit, canDelete } = useMemiBoardAuth()
|
|
25
24
|
const { categoryLabel } = useMemiBoardSettings()
|
|
26
|
-
// post.commentCount는 상세 진입 시점의 스냅샷이라 새 댓글이 즉시 반영되지 않는다 — 실시간 댓글 목록 길이로 표시한다.
|
|
27
|
-
const { comments } = useMemiBoardComments(props.postId)
|
|
28
25
|
|
|
29
26
|
const post = ref<PostDetail | null>(null)
|
|
30
27
|
const loading = ref(true)
|
|
@@ -188,7 +185,7 @@ const contentHtml = computed(() => {
|
|
|
188
185
|
</div>
|
|
189
186
|
</div>
|
|
190
187
|
|
|
191
|
-
<nav class="grid grid-cols-3 items-center
|
|
188
|
+
<nav class="grid grid-cols-3 items-center py-3" aria-label="게시글 이동">
|
|
192
189
|
<UButton
|
|
193
190
|
variant="ghost"
|
|
194
191
|
color="neutral"
|
|
@@ -219,10 +216,10 @@ const contentHtml = computed(() => {
|
|
|
219
216
|
|
|
220
217
|
<section class="flex flex-col gap-4 border-t border-default pt-4">
|
|
221
218
|
<h2 class="text-sm font-medium text-muted">
|
|
222
|
-
댓글
|
|
219
|
+
댓글
|
|
223
220
|
</h2>
|
|
224
|
-
<MemiBoardCommentForm :post-id="postId" />
|
|
225
221
|
<MemiBoardCommentList :post-id="postId" />
|
|
222
|
+
<MemiBoardCommentForm :post-id="postId" />
|
|
226
223
|
</section>
|
|
227
224
|
</div>
|
|
228
225
|
</template>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, ref, watch } from 'vue'
|
|
2
|
+
import { computed, onMounted, ref, watch } from 'vue'
|
|
3
3
|
import { slugify, useMemiBoardAuth, useMemiBoardSettings } from 'memi-board'
|
|
4
|
-
import type { BoardCategory, BoardListView } from 'memi-board'
|
|
4
|
+
import type { BoardCategory, BoardListView, BoardWriteRole } from 'memi-board'
|
|
5
5
|
|
|
6
6
|
const props = withDefaults(defineProps<{
|
|
7
7
|
/** 호스트 자체 관리자 권한도 허용할 때 true. Firebase rules 권한은 호스트가 별도로 맞춰야 한다. */
|
|
@@ -24,11 +24,20 @@ const listViewOptions: Array<{ label: string, value: BoardListView }> = [
|
|
|
24
24
|
{ label: '이미지', value: 'image' },
|
|
25
25
|
{ label: '영상', value: 'video' },
|
|
26
26
|
]
|
|
27
|
+
const writeRoleOptions: Array<{ label: string, value: BoardWriteRole }> = [
|
|
28
|
+
{ label: '일반 이상', value: 'user' },
|
|
29
|
+
{ label: '스태프 이상', value: 'staff' },
|
|
30
|
+
{ label: '관리자만', value: 'admin' },
|
|
31
|
+
]
|
|
27
32
|
|
|
28
33
|
const { isAdmin, rolePending } = useMemiBoardAuth()
|
|
29
34
|
const { categories, settingsPending, saveCategory, saveCategories, deleteCategory } = useMemiBoardSettings()
|
|
30
35
|
const canManage = computed(() => isAdmin.value || props.authorized)
|
|
31
|
-
|
|
36
|
+
// Firebase의 SSR pending 값과 클라이언트 첫 값이 달라질 수 있다. 최초 hydration은
|
|
37
|
+
// 양쪽 모두 로딩 화면으로 맞춘 뒤 mounted 이후 실제 상태를 렌더링한다.
|
|
38
|
+
const mounted = ref(false)
|
|
39
|
+
onMounted(() => { mounted.value = true })
|
|
40
|
+
const pending = computed(() => !mounted.value || rolePending.value || settingsPending.value)
|
|
32
41
|
|
|
33
42
|
const draft = ref<BoardCategory[]>([])
|
|
34
43
|
const newLabel = ref('')
|
|
@@ -45,6 +54,7 @@ watch([categories, settingsPending], ([list, loading]) => {
|
|
|
45
54
|
draft.value = list.map(category => ({
|
|
46
55
|
...category,
|
|
47
56
|
listView: category.listView ?? 'default',
|
|
57
|
+
writeRole: category.writeRole ?? 'user',
|
|
48
58
|
}))
|
|
49
59
|
}
|
|
50
60
|
}, { immediate: true })
|
|
@@ -56,7 +66,7 @@ function addCategory() {
|
|
|
56
66
|
let id = base
|
|
57
67
|
let suffix = 2
|
|
58
68
|
while (draft.value.some(category => category.id === id)) id = `${base}-${suffix++}`
|
|
59
|
-
draft.value.push({ id, label, listView: 'default' })
|
|
69
|
+
draft.value.push({ id, label, listView: 'default', writeRole: 'user' })
|
|
60
70
|
newLabel.value = ''
|
|
61
71
|
}
|
|
62
72
|
|
|
@@ -164,6 +174,16 @@ async function runDeleteAll() {
|
|
|
164
174
|
<div><p class="text-sm font-medium">카테고리 ID</p><p class="text-xs text-muted">주소와 데이터 기준값</p></div>
|
|
165
175
|
<UInput :model-value="category.id" disabled class="font-mono" />
|
|
166
176
|
</div>
|
|
177
|
+
<div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
|
|
178
|
+
<div><p class="text-sm font-medium">글쓰기 권한</p><p class="text-xs text-muted">이 카테고리에 글을 쓸 수 있는 최소 역할</p></div>
|
|
179
|
+
<USelect
|
|
180
|
+
v-model="category.writeRole"
|
|
181
|
+
:items="writeRoleOptions"
|
|
182
|
+
value-key="value"
|
|
183
|
+
label-key="label"
|
|
184
|
+
@update:model-value="savedId = null"
|
|
185
|
+
/>
|
|
186
|
+
</div>
|
|
167
187
|
<div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
|
|
168
188
|
<div><p class="text-sm font-medium">표시 라벨</p><p class="text-xs text-muted">사용자에게 보이는 이름</p></div>
|
|
169
189
|
<UInput v-model="category.label" @update:model-value="savedId = null" />
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import { useMemiBoardAuth } from '../composables/useMemiBoardAuth'
|
|
4
|
+
import { BOARD_USER_ROLES, useMemiBoardUsers } from '../composables/useMemiBoardUsers'
|
|
5
|
+
import type { BoardUserRole } from '../types'
|
|
6
|
+
|
|
7
|
+
const props = withDefaults(defineProps<{ authorized?: boolean }>(), { authorized: false })
|
|
8
|
+
const { isAdmin, rolePending } = useMemiBoardAuth()
|
|
9
|
+
const { users, usersPending, updateUserRole } = useMemiBoardUsers()
|
|
10
|
+
const canManage = computed(() => isAdmin.value || props.authorized)
|
|
11
|
+
const savingUid = ref<string | null>(null)
|
|
12
|
+
const savedUid = ref<string | null>(null)
|
|
13
|
+
const error = ref('')
|
|
14
|
+
|
|
15
|
+
async function changeRole(uid: string, role: BoardUserRole) {
|
|
16
|
+
if (!canManage.value) return
|
|
17
|
+
savingUid.value = uid
|
|
18
|
+
savedUid.value = null
|
|
19
|
+
error.value = ''
|
|
20
|
+
try {
|
|
21
|
+
await updateUserRole(uid, role)
|
|
22
|
+
savedUid.value = uid
|
|
23
|
+
}
|
|
24
|
+
catch (cause) {
|
|
25
|
+
error.value = cause instanceof Error ? cause.message : '역할을 변경하지 못했습니다.'
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
savingUid.value = null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<div class="flex flex-col gap-6">
|
|
35
|
+
<div class="grid gap-3 md:grid-cols-3">
|
|
36
|
+
<UAlert v-for="item in BOARD_USER_ROLES" :key="item.value" color="neutral" variant="subtle" :title="item.label" :description="item.description" />
|
|
37
|
+
</div>
|
|
38
|
+
<UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" :description="error" />
|
|
39
|
+
<div v-if="rolePending || usersPending" class="py-12 text-center text-sm text-muted">사용자 목록을 불러오고 있습니다.</div>
|
|
40
|
+
<UAlert v-else-if="!canManage" color="error" variant="subtle" icon="i-lucide-shield-alert" title="권한이 없습니다" description="게시판 관리자만 사용자 역할을 관리할 수 있습니다." />
|
|
41
|
+
<div v-else-if="users.length === 0" class="py-12 text-center text-sm text-muted">게시판에 로그인한 사용자가 없습니다.</div>
|
|
42
|
+
<div v-else class="divide-y divide-default rounded-lg border border-default">
|
|
43
|
+
<div v-for="boardUser in users" :key="boardUser.id" class="grid gap-4 p-4 md:grid-cols-[minmax(0,1fr)_12rem] md:items-center">
|
|
44
|
+
<div class="min-w-0">
|
|
45
|
+
<div class="flex items-center gap-2">
|
|
46
|
+
<p class="truncate font-medium text-highlighted">{{ boardUser.displayName || '이름 없음' }}</p>
|
|
47
|
+
<UBadge v-if="savedUid === boardUser.id" color="success" variant="subtle" label="저장됨" />
|
|
48
|
+
</div>
|
|
49
|
+
<p class="truncate text-sm text-muted">{{ boardUser.email || boardUser.id }}</p>
|
|
50
|
+
<p v-if="boardUser.moderationBlockCount" class="mt-1 text-xs text-warning">콘텐츠 경고 {{ boardUser.moderationBlockCount }}회</p>
|
|
51
|
+
</div>
|
|
52
|
+
<USelect
|
|
53
|
+
:model-value="boardUser.role || 'user'"
|
|
54
|
+
:items="BOARD_USER_ROLES"
|
|
55
|
+
value-key="value"
|
|
56
|
+
label-key="label"
|
|
57
|
+
:loading="savingUid === boardUser.id"
|
|
58
|
+
:disabled="savingUid === boardUser.id"
|
|
59
|
+
@update:model-value="changeRole(boardUser.id, $event as BoardUserRole)"
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</template>
|
package/dist/types.d.ts
CHANGED
|
@@ -56,11 +56,30 @@ export interface CommentModel {
|
|
|
56
56
|
authorPhoto: string | null;
|
|
57
57
|
moderationStatus: ModerationStatus;
|
|
58
58
|
createdAt: Timestamp;
|
|
59
|
+
/** 최상위 댓글은 null, 답글은 직접 답변 대상 댓글 ID. */
|
|
60
|
+
parentId?: string | null;
|
|
61
|
+
/** 스레드의 최상위 댓글 ID. */
|
|
62
|
+
rootId?: string;
|
|
63
|
+
/** 화면 들여쓰기 깊이. 0 → 1 → 2, 이후 2로 고정. */
|
|
64
|
+
depth?: number;
|
|
65
|
+
replyToUid?: string | null;
|
|
66
|
+
replyToName?: string | null;
|
|
67
|
+
/** 최상위 댓글에 저장하는 전체 답글 수. */
|
|
68
|
+
replyCount?: number;
|
|
69
|
+
isReply?: boolean;
|
|
59
70
|
}
|
|
60
|
-
export
|
|
61
|
-
|
|
71
|
+
export type BoardUserRole = 'admin' | 'staff' | 'user';
|
|
72
|
+
export interface BoardUserModel {
|
|
73
|
+
id: string;
|
|
74
|
+
role: BoardUserRole;
|
|
75
|
+
displayName?: string | null;
|
|
76
|
+
email?: string | null;
|
|
77
|
+
photoURL?: string | null;
|
|
78
|
+
moderationBlockCount?: number;
|
|
79
|
+
updatedAt?: Timestamp;
|
|
62
80
|
}
|
|
63
81
|
export type BoardListView = 'default' | 'image' | 'video';
|
|
82
|
+
export type BoardWriteRole = 'user' | 'staff' | 'admin';
|
|
64
83
|
export interface BoardCategory {
|
|
65
84
|
/** 글의 category 필드가 참조하는 키 (예: 'notice', 'free') */
|
|
66
85
|
id: string;
|
|
@@ -68,6 +87,8 @@ export interface BoardCategory {
|
|
|
68
87
|
label: string;
|
|
69
88
|
/** 목록 표시 방식. 미지정된 기존 데이터는 default. */
|
|
70
89
|
listView?: BoardListView;
|
|
90
|
+
/** 글쓰기 최소 역할. 미지정된 기존 데이터는 user. */
|
|
91
|
+
writeRole?: BoardWriteRole;
|
|
71
92
|
/** 목록 노출 순서. 서브컬렉션 문서의 정렬 필드. */
|
|
72
93
|
order?: number;
|
|
73
94
|
}
|