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.
@@ -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>
@@ -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
- const pending = computed(() => rolePending.value || settingsPending.value)
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 interface BoardUserRole {
61
- role: 'user' | 'admin';
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
  }