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.
@@ -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 { useMemiBoardAuth } from 'memi-board'
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, deleteComment } = useMemiBoardComments(props.postId)
10
- const { canDeleteComment } = useMemiBoardAuth()
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
- const deletingId = ref<string | null>(null)
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
- async function handleDelete(commentId: string) {
15
- if (!window.confirm('이 댓글을 삭제하시겠습니까?')) return
16
- deletingId.value = commentId
17
- try {
18
- await deleteComment(commentId)
19
- }
20
- finally {
21
- deletingId.value = null
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
- <div
81
+ <MemiBoardCommentThread
36
82
  v-for="comment in comments"
37
83
  :key="comment.id"
38
- class="flex items-start gap-3"
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
- v-if="canDeleteComment(comment)"
56
- icon="i-lucide-trash-2"
57
- size="xs"
58
- variant="ghost"
59
- color="error"
60
- :loading="deletingId === comment.id"
61
- @click="handleDelete(comment.id!)"
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 border-y border-default py-3" aria-label="게시글 이동">
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
- 댓글 {{ comments.length }}
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, delete: if isOwner(resource) || isStaff();
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.moderationStatus == 'approved';
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
- // 댓글 수정은 지원하지 않는다 (삭제 후 재작성만 가능) — Editor에서 UI 자체도 제공하지 않음
69
- allow update: if false;
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.7.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",