memi-board 0.32.0 → 0.37.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.
Files changed (49) hide show
  1. package/README.md +17 -2
  2. package/dist/composables/boardLookup.d.ts +8 -0
  3. package/dist/composables/useMemiBoardAuth.d.ts +4 -3
  4. package/dist/composables/useMemiBoardCommentFeed.d.ts +18 -0
  5. package/dist/composables/useMemiBoardComments.d.ts +2 -0
  6. package/dist/composables/useMemiBoardLikes.d.ts +9 -0
  7. package/dist/composables/useMemiBoardMiniPosts.d.ts +12 -0
  8. package/dist/composables/useMemiBoardReports.d.ts +114 -0
  9. package/dist/composables/useMemiBoardSections.d.ts +13 -0
  10. package/dist/composables/useMemiBoardSettings.d.ts +2 -0
  11. package/dist/config.d.ts +2 -0
  12. package/dist/{heic2any-BWRKffWN.js → heic2any-D2QKy06h.js} +2 -2
  13. package/dist/{heic2any-BWRKffWN.js.map → heic2any-D2QKy06h.js.map} +1 -1
  14. package/dist/index.d.ts +12 -3
  15. package/dist/index.js +1161 -647
  16. package/dist/index.js.map +1 -1
  17. package/dist/runtime/components/CategorySettings.vue +3 -5
  18. package/dist/runtime/components/CommentFeed.vue +141 -0
  19. package/dist/runtime/components/CommentForm.vue +2 -2
  20. package/dist/runtime/components/CommentItem.vue +55 -5
  21. package/dist/runtime/components/CommentList.vue +10 -2
  22. package/dist/runtime/components/Detail.vue +8 -0
  23. package/dist/runtime/components/ListDefault.vue +2 -2
  24. package/dist/runtime/components/ListDense.vue +2 -2
  25. package/dist/runtime/components/ListVideo.vue +2 -2
  26. package/dist/runtime/components/Mini.vue +37 -0
  27. package/dist/runtime/components/MiniCard.vue +51 -0
  28. package/dist/runtime/components/MiniComments.vue +75 -0
  29. package/dist/runtime/components/MiniList.vue +36 -0
  30. package/dist/runtime/components/MiniPicker.vue +131 -0
  31. package/dist/runtime/components/MiniPost.vue +52 -0
  32. package/dist/runtime/components/MiniPosts.vue +115 -0
  33. package/dist/runtime/components/ReportButton.vue +117 -0
  34. package/dist/runtime/components/ReportList.vue +124 -0
  35. package/dist/runtime/components/Section.vue +62 -0
  36. package/dist/runtime/components/Settings.vue +3 -4
  37. package/dist/runtime/components/UserCommentList.vue +3 -0
  38. package/dist/{slugify-B5XHr4SZ.js → slugify-Cyq-Gg8X.js} +47 -36
  39. package/dist/slugify-Cyq-Gg8X.js.map +1 -0
  40. package/dist/storage.js +11 -11
  41. package/dist/types.d.ts +54 -3
  42. package/dist/utils/boardAccess.d.ts +7 -0
  43. package/dist/utils/boardPaths.d.ts +10 -3
  44. package/dist/utils/deletePostCascade.d.ts +7 -1
  45. package/dist/utils/section.d.ts +31 -0
  46. package/docs/firestore.indexes.json.example +38 -0
  47. package/docs/firestore.rules.example +78 -22
  48. package/package.json +1 -1
  49. package/dist/slugify-B5XHr4SZ.js.map +0 -1
@@ -0,0 +1,131 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref } from 'vue'
3
+ import { getDocs, limit as fbLimit, orderBy, query, where } from 'firebase/firestore'
4
+ import { useFirestore } from 'vuefire'
5
+ import type { PostModel } from 'memi-board/runtime'
6
+ import { miniPostTitle, useBoardPathConfig, useMemiBoardSettings } from 'memi-board/runtime'
7
+ import { postsCol } from '../utils/boardPaths'
8
+
9
+ const props = defineProps<{
10
+ modelValue: string[]
11
+ max: number
12
+ }>()
13
+
14
+ const emit = defineEmits<{
15
+ 'update:modelValue': [value: string[]]
16
+ }>()
17
+
18
+ const db = useFirestore()
19
+ const cfg = () => useBoardPathConfig()
20
+ const { categoryLabel } = useMemiBoardSettings()
21
+ const catalog = ref<PostModel[]>([])
22
+ const pending = ref(true)
23
+ const q = ref('')
24
+
25
+ const selected = computed(() => props.modelValue)
26
+
27
+ const filtered = computed(() => {
28
+ const needle = q.value.trim().toLowerCase()
29
+ const rows = catalog.value.filter(post => !selected.value.includes(post.id || ''))
30
+ if (!needle) return rows.slice(0, 20)
31
+ return rows.filter((post) => {
32
+ const title = miniPostTitle(post).toLowerCase()
33
+ return title.includes(needle) || (post.slug || '').toLowerCase().includes(needle)
34
+ }).slice(0, 20)
35
+ })
36
+
37
+ const picked = computed(() =>
38
+ selected.value
39
+ .map(id => catalog.value.find(post => post.id === id))
40
+ .filter((item): item is PostModel => !!item)
41
+ )
42
+
43
+ const full = computed(() => selected.value.length >= props.max)
44
+
45
+ onMounted(async () => {
46
+ try {
47
+ const snapshot = await getDocs(query(
48
+ postsCol(db, cfg()),
49
+ where('isPublished', '==', true),
50
+ where('listed', '==', true),
51
+ orderBy('createdAt', 'desc'),
52
+ fbLimit(80),
53
+ ))
54
+ catalog.value = snapshot.docs.map(item => ({ id: item.id, ...item.data() }) as PostModel)
55
+ }
56
+ finally {
57
+ pending.value = false
58
+ }
59
+ })
60
+
61
+ function add(id: string) {
62
+ if (!id || selected.value.includes(id) || full.value) return
63
+ emit('update:modelValue', [...selected.value, id])
64
+ }
65
+
66
+ function remove(id: string) {
67
+ emit('update:modelValue', selected.value.filter(item => item !== id))
68
+ }
69
+ </script>
70
+
71
+ <template>
72
+ <div class="flex flex-col gap-3 border-t border-default p-3">
73
+ <div class="flex flex-wrap items-center justify-between gap-2">
74
+ <p class="text-xs text-muted">
75
+ 고른 글 {{ selected.length }} / {{ max }}
76
+ <span v-if="max >= 2"> · 2개부터 캐러셀, 4개는 그리드</span>
77
+ </p>
78
+ <UInput
79
+ v-model="q"
80
+ size="sm"
81
+ icon="i-lucide-search"
82
+ placeholder="제목 검색"
83
+ class="w-48"
84
+ />
85
+ </div>
86
+
87
+ <div v-if="picked.length" class="flex flex-col gap-1">
88
+ <div
89
+ v-for="post in picked"
90
+ :key="post.id"
91
+ class="flex items-center gap-2 rounded-lg bg-elevated/50 px-2 py-1.5"
92
+ >
93
+ <p class="min-w-0 flex-1 truncate text-sm">
94
+ {{ miniPostTitle(post) }}
95
+ <span class="text-xs text-muted">
96
+ · {{ categoryLabel(post.category || post.boardId) }}
97
+ </span>
98
+ </p>
99
+ <UButton
100
+ icon="i-lucide-x"
101
+ size="xs"
102
+ color="neutral"
103
+ variant="ghost"
104
+ square
105
+ @click="remove(post.id!)"
106
+ />
107
+ </div>
108
+ </div>
109
+
110
+ <p v-if="pending" class="text-xs text-muted">글을 불러오는 중…</p>
111
+ <ul v-else class="flex max-h-48 flex-col gap-0.5 overflow-y-auto">
112
+ <li v-for="post in filtered" :key="post.id">
113
+ <button
114
+ type="button"
115
+ class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-elevated disabled:opacity-40"
116
+ :disabled="full"
117
+ @click="add(post.id!)"
118
+ >
119
+ <UIcon name="i-lucide-plus" class="size-3.5 shrink-0 text-muted" />
120
+ <span class="min-w-0 truncate">{{ miniPostTitle(post) }}</span>
121
+ <span class="shrink-0 text-[11px] text-muted">
122
+ {{ categoryLabel(post.category || post.boardId) }}
123
+ </span>
124
+ </button>
125
+ </li>
126
+ <li v-if="!filtered.length" class="px-2 py-3 text-center text-xs text-muted">
127
+ 더 고를 글이 없습니다.
128
+ </li>
129
+ </ul>
130
+ </div>
131
+ </template>
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { BoardSection, PostModel } from 'memi-board/runtime'
4
+ import { miniPostsView, miniViewGroup, useMemiBoardMiniPicked, useMemiBoardSettings } from 'memi-board/runtime'
5
+ import MemiBoardMiniCard from './MiniCard.vue'
6
+ import MemiBoardMiniPosts from './MiniPosts.vue'
7
+
8
+ const props = defineProps<{
9
+ section: BoardSection
10
+ getPostLink?: (post: PostModel) => string | undefined
11
+ }>()
12
+
13
+ const { posts, pending } = useMemiBoardMiniPicked(() => props.section.postIds)
14
+ const { getBoard } = useMemiBoardSettings()
15
+ const view = computed(() => miniPostsView(posts.value, getBoard))
16
+ const useCarousel = computed(() =>
17
+ miniViewGroup(view.value) === 'list' && posts.value.length >= 2 && posts.value.length !== 4,
18
+ )
19
+ </script>
20
+
21
+ <template>
22
+ <div class="flex min-h-0 flex-1 flex-col">
23
+ <div v-if="pending" class="grid grid-cols-2 gap-2 p-2">
24
+ <USkeleton v-for="i in Math.min(section.count, 4)" :key="i" class="aspect-[4/3] w-full" />
25
+ </div>
26
+ <p
27
+ v-else-if="!posts.length"
28
+ class="flex flex-1 items-center justify-center px-3 py-6 text-sm text-muted"
29
+ >
30
+ 고른 게시물이 없습니다.
31
+ </p>
32
+ <UCarousel
33
+ v-else-if="useCarousel"
34
+ v-slot="{ item }"
35
+ :items="posts"
36
+ arrows
37
+ dots
38
+ loop
39
+ :ui="{ item: 'basis-full ps-0' }"
40
+ class="min-w-0 p-2"
41
+ >
42
+ <MemiBoardMiniCard :post="item" :get-post-link="getPostLink" />
43
+ </UCarousel>
44
+ <MemiBoardMiniPosts
45
+ v-else
46
+ :posts="posts"
47
+ :view="view"
48
+ show-board
49
+ :get-post-link="getPostLink"
50
+ />
51
+ </div>
52
+ </template>
@@ -0,0 +1,115 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, resolveComponent } from 'vue'
3
+ import type { BoardListView, PostModel } from 'memi-board/runtime'
4
+ import {
5
+ formatRelativeDate,
6
+ miniPostImage,
7
+ miniPostLink,
8
+ miniPostTitle,
9
+ miniViewGroup,
10
+ useMemiBoardSettings,
11
+ videoListCoverUrl,
12
+ } from 'memi-board/runtime'
13
+
14
+ const props = defineProps<{
15
+ posts: PostModel[]
16
+ view: BoardListView
17
+ showBoard?: boolean
18
+ getPostLink?: (post: PostModel) => string | undefined
19
+ }>()
20
+
21
+ const NuxtLink = resolveComponent('NuxtLink')
22
+ const now = ref(Date.now())
23
+ const { categoryLabel } = useMemiBoardSettings()
24
+ const group = computed(() => miniViewGroup(props.view))
25
+
26
+ function cover(post: PostModel) {
27
+ return videoListCoverUrl(post) || miniPostImage(post)
28
+ }
29
+
30
+ function isVideo(post: PostModel) {
31
+ return props.view === 'video' || Boolean(post.videoUrl)
32
+ }
33
+
34
+ function postTo(post: PostModel) {
35
+ return props.getPostLink?.(post) ?? miniPostLink(post)
36
+ }
37
+ </script>
38
+
39
+ <template>
40
+ <div
41
+ v-if="group === 'media'"
42
+ class="grid grid-cols-2 gap-1.5 p-1.5"
43
+ >
44
+ <component
45
+ :is="postTo(post) ? NuxtLink : 'div'"
46
+ v-for="post in posts"
47
+ :key="post.id"
48
+ :to="postTo(post)"
49
+ class="group relative aspect-[4/3] overflow-hidden rounded-lg bg-elevated"
50
+ >
51
+ <img
52
+ v-if="cover(post)"
53
+ :src="cover(post)"
54
+ :alt="miniPostTitle(post)"
55
+ class="size-full object-cover transition duration-300 group-hover:scale-105"
56
+ >
57
+ <div v-else class="flex size-full items-center justify-center text-muted">
58
+ <UIcon :name="isVideo(post) ? 'i-lucide-video' : 'i-lucide-image'" class="size-7" />
59
+ </div>
60
+ <span
61
+ v-if="isVideo(post)"
62
+ class="absolute inset-0 flex items-center justify-center bg-black/10"
63
+ >
64
+ <span class="flex size-9 items-center justify-center rounded-full bg-black/70 text-white">
65
+ <UIcon name="i-lucide-play" class="ml-0.5 size-4 fill-current" />
66
+ </span>
67
+ </span>
68
+ <span class="absolute right-1.5 top-1.5 inline-flex items-center gap-0.5 rounded-full bg-black/55 px-1.5 py-px text-[11px] tabular-nums text-white">
69
+ <UIcon name="i-lucide-message-square" class="size-3" />
70
+ {{ post.commentCount ?? 0 }}
71
+ </span>
72
+ </component>
73
+ </div>
74
+
75
+ <ul
76
+ v-else
77
+ class="flex min-h-0 flex-1 flex-col divide-y divide-default"
78
+ >
79
+ <li v-for="post in posts" :key="post.id">
80
+ <component
81
+ :is="postTo(post) ? NuxtLink : 'div'"
82
+ :to="postTo(post)"
83
+ class="memi-board-post-link flex items-center gap-2 px-3 py-2 text-highlighted transition-colors hover:bg-elevated/60"
84
+ >
85
+ <div class="min-w-0 flex-1">
86
+ <div class="flex min-w-0 items-start gap-1.5">
87
+ <p class="min-w-0 flex-1 truncate text-sm font-medium text-inherit">
88
+ {{ miniPostTitle(post) }}
89
+ </p>
90
+ <span class="mt-0.5 inline-flex shrink-0 items-center gap-0.5 text-[11px] tabular-nums text-muted">
91
+ <UIcon name="i-lucide-message-square" class="size-3" />
92
+ {{ post.commentCount ?? 0 }}
93
+ </span>
94
+ </div>
95
+ <p class="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-[11px] text-muted">
96
+ <span v-if="showBoard && (post.boardId || post.category)">
97
+ {{ categoryLabel(post.category || post.boardId) }}
98
+ </span>
99
+ <time>{{ formatRelativeDate(post.createdAt, now) }}</time>
100
+ </p>
101
+ </div>
102
+ <div
103
+ v-if="miniPostImage(post)"
104
+ class="size-11 shrink-0 overflow-hidden rounded-md bg-elevated"
105
+ >
106
+ <img
107
+ :src="miniPostImage(post)!"
108
+ :alt="miniPostTitle(post)"
109
+ class="size-full object-cover"
110
+ >
111
+ </div>
112
+ </component>
113
+ </li>
114
+ </ul>
115
+ </template>
@@ -0,0 +1,117 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import {
4
+ REPORT_DETAIL_MAX_LENGTH,
5
+ REPORT_REASONS,
6
+ useMemiBoardAuth,
7
+ useMemiBoardReports,
8
+ } from 'memi-board/runtime'
9
+ import type { BoardReportReason } from 'memi-board/runtime'
10
+
11
+ const props = defineProps<{
12
+ boardId: string
13
+ postId: string
14
+ authorUid: string
15
+ authorName?: string | null
16
+ postTitle?: string | null
17
+ }>()
18
+
19
+ const { isSignedIn, user } = useMemiBoardAuth()
20
+ const { hasReported, pending, submitReport } = useMemiBoardReports(props.boardId, props.postId)
21
+
22
+ const open = ref(false)
23
+ const reason = ref<BoardReportReason>('spam')
24
+ const detail = ref('')
25
+ const error = ref('')
26
+ const done = ref(false)
27
+
28
+ const isOwnPost = computed(() => !!user.value && user.value.uid === props.authorUid)
29
+ const canReport = computed(() => isSignedIn.value && !isOwnPost.value)
30
+
31
+ function openModal() {
32
+ if (hasReported.value || pending.value) return
33
+ reason.value = 'spam'
34
+ detail.value = ''
35
+ error.value = ''
36
+ done.value = false
37
+ open.value = true
38
+ }
39
+
40
+ async function submit() {
41
+ error.value = ''
42
+ try {
43
+ await submitReport({
44
+ reason: reason.value,
45
+ detail: detail.value,
46
+ postTitle: props.postTitle || '',
47
+ authorUid: props.authorUid,
48
+ authorName: props.authorName ?? null,
49
+ })
50
+ done.value = true
51
+ }
52
+ catch (cause) {
53
+ error.value = cause instanceof Error ? cause.message : '신고하지 못했습니다.'
54
+ }
55
+ }
56
+ </script>
57
+
58
+ <template>
59
+ <template v-if="canReport">
60
+ <UButton
61
+ icon="i-lucide-flag"
62
+ size="sm"
63
+ variant="ghost"
64
+ :color="hasReported ? 'warning' : 'neutral'"
65
+ :label="hasReported ? '신고함' : '신고'"
66
+ :disabled="hasReported"
67
+ @click="openModal"
68
+ />
69
+ <UModal v-model:open="open" title="게시물 신고">
70
+ <template #body>
71
+ <div v-if="done" class="py-4 text-sm text-muted">
72
+ 신고를 접수했습니다. 스태프가 내용을 확인합니다.
73
+ </div>
74
+ <form
75
+ v-else
76
+ class="flex flex-col gap-3"
77
+ @submit.prevent="submit"
78
+ >
79
+ <URadioGroup
80
+ v-model="reason"
81
+ :items="REPORT_REASONS"
82
+ value-key="value"
83
+ legend="사유"
84
+ />
85
+ <UTextarea
86
+ v-model="detail"
87
+ :rows="3"
88
+ :maxlength="REPORT_DETAIL_MAX_LENGTH"
89
+ placeholder="자세한 내용 (선택)"
90
+ />
91
+ <p class="text-right text-xs text-dimmed">
92
+ {{ detail.length.toLocaleString() }} / {{ REPORT_DETAIL_MAX_LENGTH.toLocaleString() }}
93
+ </p>
94
+ <p v-if="error" class="text-xs text-error">
95
+ {{ error }}
96
+ </p>
97
+ <div class="flex justify-end gap-2">
98
+ <UButton
99
+ type="button"
100
+ color="neutral"
101
+ variant="ghost"
102
+ label="취소"
103
+ :disabled="pending"
104
+ @click="open = false"
105
+ />
106
+ <UButton
107
+ type="submit"
108
+ color="warning"
109
+ label="신고하기"
110
+ :loading="pending"
111
+ />
112
+ </div>
113
+ </form>
114
+ </template>
115
+ </UModal>
116
+ </template>
117
+ </template>
@@ -0,0 +1,124 @@
1
+ <script setup lang="ts">
2
+ import { ref, resolveComponent } from 'vue'
3
+ import {
4
+ formatRelativeDate,
5
+ reportReasonLabel,
6
+ useMemiBoardAuth,
7
+ useMemiBoardReportQueue,
8
+ } from 'memi-board/runtime'
9
+ import type { BoardReportModel } from 'memi-board/runtime'
10
+
11
+ const props = defineProps<{
12
+ boardId?: string
13
+ getPostLink?: (report: BoardReportModel) => string | undefined
14
+ }>()
15
+
16
+ const NuxtLink = resolveComponent('NuxtLink')
17
+ const { canManageContent } = useMemiBoardAuth()
18
+ const { reports, pending, loadingMore, hasMore, loadError, loadMore, dismissReport, actionReport } = useMemiBoardReportQueue({
19
+ boardId: () => props.boardId || '',
20
+ })
21
+ const now = ref(Date.now())
22
+ const actingId = ref<string | null>(null)
23
+
24
+ function postLink(report: BoardReportModel) {
25
+ return props.getPostLink?.(report)
26
+ }
27
+
28
+ async function handle(report: BoardReportModel, action: 'dismiss' | 'action') {
29
+ if (!report.id) return
30
+ actingId.value = report.id
31
+ try {
32
+ if (action === 'dismiss') await dismissReport(report)
33
+ else await actionReport(report)
34
+ }
35
+ finally {
36
+ actingId.value = null
37
+ }
38
+ }
39
+ </script>
40
+
41
+ <template>
42
+ <div class="flex flex-col gap-3">
43
+ <p
44
+ v-if="!canManageContent"
45
+ class="py-8 text-center text-sm text-muted"
46
+ >
47
+ 스태프만 신고 목록을 볼 수 있습니다.
48
+ </p>
49
+ <template v-else-if="pending">
50
+ <USkeleton v-for="i in 4" :key="i" class="h-16 w-full" />
51
+ </template>
52
+ <p
53
+ v-else-if="loadError"
54
+ class="py-8 text-center text-sm text-error"
55
+ >
56
+ 목록을 불러오지 못했습니다: {{ loadError }}
57
+ </p>
58
+ <p
59
+ v-else-if="!reports.length"
60
+ class="py-8 text-center text-sm text-muted"
61
+ >
62
+ 대기 중인 신고가 없습니다.
63
+ </p>
64
+ <ul
65
+ v-else
66
+ class="flex flex-col divide-y divide-default overflow-hidden rounded-lg border border-default"
67
+ >
68
+ <li
69
+ v-for="report in reports"
70
+ :key="report.id"
71
+ class="flex flex-col gap-2 px-3 py-3 sm:flex-row sm:items-start sm:justify-between"
72
+ >
73
+ <div class="min-w-0 flex-1">
74
+ <component
75
+ :is="postLink(report) ? NuxtLink : 'p'"
76
+ :to="postLink(report)"
77
+ class="truncate text-sm font-medium text-highlighted"
78
+ :class="postLink(report) ? 'hover:underline' : ''"
79
+ >
80
+ {{ report.postTitle || '제목 없는 글' }}
81
+ </component>
82
+ <p class="mt-0.5 text-xs text-muted">
83
+ {{ reportReasonLabel(report.reason) }}
84
+ <span v-if="report.authorName"> · 작성 {{ report.authorName }}</span>
85
+ · {{ formatRelativeDate(report.createdAt, now) }}
86
+ </p>
87
+ <p
88
+ v-if="report.detail"
89
+ class="mt-1 line-clamp-3 text-sm text-toned"
90
+ >
91
+ {{ report.detail }}
92
+ </p>
93
+ </div>
94
+ <div class="flex shrink-0 items-center gap-1">
95
+ <UButton
96
+ size="xs"
97
+ color="neutral"
98
+ variant="soft"
99
+ label="기각"
100
+ :loading="actingId === report.id"
101
+ @click="handle(report, 'dismiss')"
102
+ />
103
+ <UButton
104
+ size="xs"
105
+ color="warning"
106
+ variant="soft"
107
+ label="확인"
108
+ :loading="actingId === report.id"
109
+ @click="handle(report, 'action')"
110
+ />
111
+ </div>
112
+ </li>
113
+ </ul>
114
+ <UButton
115
+ v-if="canManageContent && !pending && hasMore"
116
+ variant="outline"
117
+ color="neutral"
118
+ label="더 보기"
119
+ block
120
+ :loading="loadingMore"
121
+ @click="loadMore"
122
+ />
123
+ </div>
124
+ </template>
@@ -0,0 +1,62 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { BoardSection, CommentModel, PostModel } from 'memi-board/runtime'
4
+ import { miniMoreLink, sectionColClass, sectionMinHeight } from 'memi-board/runtime'
5
+ import MemiBoardMiniComments from './MiniComments.vue'
6
+ import MemiBoardMiniList from './MiniList.vue'
7
+ import MemiBoardMiniPost from './MiniPost.vue'
8
+
9
+ const props = defineProps<{
10
+ section: BoardSection
11
+ getPostLink?: (post: PostModel) => string | undefined
12
+ getCommentLink?: (comment: CommentModel) => string | undefined
13
+ getMoreLink?: (section: BoardSection) => string | undefined
14
+ }>()
15
+
16
+ const moreTo = computed(() => props.getMoreLink?.(props.section) ?? miniMoreLink(props.section))
17
+ </script>
18
+
19
+ <template>
20
+ <article
21
+ class="flex flex-col overflow-hidden rounded-xl border border-default bg-default"
22
+ :class="[sectionColClass(section.cols), sectionMinHeight(section.height)]"
23
+ >
24
+ <header
25
+ v-if="section.showTitle || section.kind === 'list' || section.kind === 'comments'"
26
+ class="flex items-center justify-between gap-2 border-b border-default px-3 py-2"
27
+ >
28
+ <h2
29
+ v-if="section.showTitle"
30
+ class="min-w-0 truncate text-sm font-semibold text-highlighted"
31
+ >
32
+ {{ section.title }}
33
+ </h2>
34
+ <span v-else class="min-w-0" />
35
+ <UButton
36
+ v-if="section.kind === 'list' || section.kind === 'comments'"
37
+ :to="moreTo"
38
+ variant="link"
39
+ color="neutral"
40
+ size="xs"
41
+ trailing-icon="i-lucide-chevron-right"
42
+ class="shrink-0 px-0"
43
+ label="더 보기"
44
+ />
45
+ </header>
46
+ <MemiBoardMiniList
47
+ v-if="section.kind === 'list'"
48
+ :section="section"
49
+ :get-post-link="getPostLink"
50
+ />
51
+ <MemiBoardMiniComments
52
+ v-else-if="section.kind === 'comments'"
53
+ :section="section"
54
+ :get-comment-link="getCommentLink"
55
+ />
56
+ <MemiBoardMiniPost
57
+ v-else
58
+ :section="section"
59
+ :get-post-link="getPostLink"
60
+ />
61
+ </article>
62
+ </template>
@@ -56,8 +56,7 @@ const staffOptions = computed(() => users.value
56
56
  .filter(item => item.role === 'staff')
57
57
  .map(item => ({ label: item.displayName || item.id, value: item.id })))
58
58
  function needsStaffPicker(category: BoardCategory) {
59
- return (category.writeRole === 'staff' || category.commentWriteRole === 'staff')
60
- && canManageStaffAssignment.value
59
+ return canManageStaffAssignment.value
61
60
  }
62
61
  // Firebase의 SSR pending 값과 클라이언트 첫 값이 달라질 수 있다. 최초 hydration은
63
62
  // 양쪽 모두 로딩 화면으로 맞춘 뒤 mounted 이후 실제 상태를 렌더링한다.
@@ -247,14 +246,14 @@ async function runDeleteAll() {
247
246
  />
248
247
  </div>
249
248
  <div v-if="needsStaffPicker(category)" class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
250
- <div><p class="text-sm font-medium">허용 스태프</p><p class="text-xs text-muted">비워두면 스태프 전체 허용, 지정하면 이 사람들만</p></div>
249
+ <div><p class="text-sm font-medium">담당 스태프</p><p class="text-xs text-muted">지정한 스태프만 이 보드를 설정·관리합니다. 비우면 관리자만</p></div>
251
250
  <USelectMenu
252
251
  v-model="category.allowedStaffUids"
253
252
  :items="staffOptions"
254
253
  value-key="value"
255
254
  label-key="label"
256
255
  multiple
257
- placeholder="스태프 전체 허용"
256
+ placeholder="관리자만 (스태프 미지정)"
258
257
  @update:model-value="savedId = null"
259
258
  />
260
259
  </div>
@@ -85,6 +85,9 @@ function commentLink(comment: CommentModel): string | undefined {
85
85
  </p>
86
86
  <p class="mt-1 text-xs text-muted">
87
87
  {{ formatRelativeDate(comment.createdAt, now) }}
88
+ <template v-if="(comment.likeCount ?? 0) > 0">
89
+ · 좋아요 {{ comment.likeCount }}
90
+ </template>
88
91
  </p>
89
92
  </component>
90
93
  </li>