memi-board 0.7.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 +2 -0
- package/dist/composables/useMemiBoardComments.d.ts +81 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +457 -282
- package/dist/index.js.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/types.d.ts +11 -0
- package/docs/firestore.rules.example +65 -4
- 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>
|
package/dist/types.d.ts
CHANGED
|
@@ -56,6 +56,17 @@ 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
71
|
export type BoardUserRole = 'admin' | 'staff' | 'user';
|
|
61
72
|
export interface BoardUserModel {
|
|
@@ -34,6 +34,14 @@ service cloud.firestore {
|
|
|
34
34
|
function isOwner(resource) {
|
|
35
35
|
return isSignedIn() && request.auth.uid == resource.data.authorUid;
|
|
36
36
|
}
|
|
37
|
+
function isCommentCountUpdate() {
|
|
38
|
+
return isSignedIn()
|
|
39
|
+
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['commentCount'])
|
|
40
|
+
&& request.resource.data.commentCount is int
|
|
41
|
+
&& request.resource.data.commentCount >= 0
|
|
42
|
+
&& (request.resource.data.commentCount == resource.data.get('commentCount', 0) + 1
|
|
43
|
+
|| request.resource.data.commentCount == resource.data.get('commentCount', 0) - 1);
|
|
44
|
+
}
|
|
37
45
|
|
|
38
46
|
match /memiBoardPosts/{postId} {
|
|
39
47
|
allow read: if true;
|
|
@@ -45,7 +53,8 @@ service cloud.firestore {
|
|
|
45
53
|
&& canWriteCategory(request.resource.data.category);
|
|
46
54
|
|
|
47
55
|
// 작성자 본인 또는 관리자·스태프만 수정/삭제 가능
|
|
48
|
-
allow update
|
|
56
|
+
allow update: if isOwner(resource) || isStaff() || isCommentCountUpdate();
|
|
57
|
+
allow delete: if isOwner(resource) || isStaff();
|
|
49
58
|
|
|
50
59
|
match /body/{docId} {
|
|
51
60
|
allow read: if true;
|
|
@@ -55,9 +64,61 @@ service cloud.firestore {
|
|
|
55
64
|
match /comments/{commentId} {
|
|
56
65
|
allow read: if true;
|
|
57
66
|
|
|
67
|
+
function isLegacyTopLevelComment() {
|
|
68
|
+
return !request.resource.data.keys().hasAny([
|
|
69
|
+
'parentId', 'rootId', 'depth', 'replyToUid', 'replyToName', 'replyCount', 'isReply'
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
function isValidTopLevelComment() {
|
|
73
|
+
return request.resource.data.parentId == null
|
|
74
|
+
&& request.resource.data.rootId == commentId
|
|
75
|
+
&& request.resource.data.depth == 0
|
|
76
|
+
&& request.resource.data.replyToUid == null
|
|
77
|
+
&& request.resource.data.replyToName == null
|
|
78
|
+
&& request.resource.data.replyCount == 0
|
|
79
|
+
&& request.resource.data.isReply == false;
|
|
80
|
+
}
|
|
81
|
+
function isValidReply() {
|
|
82
|
+
let parentPath = /databases/$(database)/documents/memiBoardPosts/$(postId)/comments/$(request.resource.data.parentId);
|
|
83
|
+
let rootPath = /databases/$(database)/documents/memiBoardPosts/$(postId)/comments/$(request.resource.data.rootId);
|
|
84
|
+
let parent = get(parentPath).data;
|
|
85
|
+
return request.resource.data.parentId is string
|
|
86
|
+
&& request.resource.data.rootId is string
|
|
87
|
+
&& request.resource.data.parentId != commentId
|
|
88
|
+
&& exists(parentPath)
|
|
89
|
+
&& exists(rootPath)
|
|
90
|
+
&& get(rootPath).data.get('parentId', null) == null
|
|
91
|
+
&& request.resource.data.replyToUid == parent.authorUid
|
|
92
|
+
&& request.resource.data.replyCount == 0
|
|
93
|
+
&& request.resource.data.isReply == true
|
|
94
|
+
&& ((parent.get('parentId', null) == null
|
|
95
|
+
&& request.resource.data.rootId == request.resource.data.parentId)
|
|
96
|
+
|| (parent.get('parentId', null) != null
|
|
97
|
+
&& request.resource.data.rootId == parent.rootId))
|
|
98
|
+
&& ((parent.get('depth', 0) < 2
|
|
99
|
+
&& request.resource.data.depth == parent.get('depth', 0) + 1)
|
|
100
|
+
|| (parent.get('depth', 0) >= 2
|
|
101
|
+
&& request.resource.data.depth == 2));
|
|
102
|
+
}
|
|
103
|
+
function isReplyCountUpdate() {
|
|
104
|
+
return isSignedIn()
|
|
105
|
+
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['replyCount'])
|
|
106
|
+
&& request.resource.data.replyCount is int
|
|
107
|
+
&& request.resource.data.replyCount >= 0
|
|
108
|
+
&& (request.resource.data.replyCount == resource.data.get('replyCount', 0) + 1
|
|
109
|
+
|| request.resource.data.replyCount == resource.data.get('replyCount', 0) - 1);
|
|
110
|
+
}
|
|
111
|
+
|
|
58
112
|
allow create: if isSignedIn()
|
|
59
113
|
&& request.resource.data.authorUid == request.auth.uid
|
|
60
|
-
&& request.resource.data.
|
|
114
|
+
&& request.resource.data.postId == postId
|
|
115
|
+
&& request.resource.data.body is string
|
|
116
|
+
&& request.resource.data.moderationStatus == 'approved'
|
|
117
|
+
&& (isLegacyTopLevelComment()
|
|
118
|
+
|| (request.resource.data.keys().hasAll([
|
|
119
|
+
'parentId', 'rootId', 'depth', 'replyToUid', 'replyToName', 'replyCount', 'isReply'
|
|
120
|
+
])
|
|
121
|
+
&& (isValidTopLevelComment() || isValidReply())));
|
|
61
122
|
|
|
62
123
|
// 댓글 작성자·부모 게시글 작성자·게시판 관리자 모두 삭제 가능.
|
|
63
124
|
// 부모 글을 삭제할 때 작성자가 다른 사용자의 댓글도 함께 정리할 수 있어야 한다.
|
|
@@ -65,8 +126,8 @@ service cloud.firestore {
|
|
|
65
126
|
|| isOwner(get(/databases/$(database)/documents/memiBoardPosts/$(postId)))
|
|
66
127
|
|| isStaff();
|
|
67
128
|
|
|
68
|
-
// 댓글 수정은 지원하지
|
|
69
|
-
allow update: if
|
|
129
|
+
// 댓글 본문 수정은 지원하지 않는다. 답글 batch의 replyCount ±1만 허용한다.
|
|
130
|
+
allow update: if isReplyCountUpdate();
|
|
70
131
|
}
|
|
71
132
|
}
|
|
72
133
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memi-board",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Firebase 게시판 — core composable + thin Nuxt module(UI SFC). Nuxt 4 + Nuxt UI 호스트에 붙임",
|
|
6
6
|
"license": "MIT",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"@nuxt/ui": "^4.0.0",
|
|
59
59
|
"@tiptap/core": ">=2.0.0",
|
|
60
60
|
"@tiptap/pm": ">=2.0.0",
|
|
61
|
+
"dayjs": "^1.11.0",
|
|
61
62
|
"firebase": "^12.0.0",
|
|
62
63
|
"nuxt": ">=3.0.0 || >=4.0.0",
|
|
63
64
|
"vue": "^3.5.0",
|
|
@@ -80,6 +81,7 @@
|
|
|
80
81
|
},
|
|
81
82
|
"devDependencies": {
|
|
82
83
|
"@nuxt/ui": "^4.9.0",
|
|
84
|
+
"dayjs": "^1.11.21",
|
|
83
85
|
"firebase": "^12.15.0",
|
|
84
86
|
"nuxt": "^4.5.1",
|
|
85
87
|
"nuxt-vuefire": "^1.1.2",
|