memi-board 0.3.1 → 0.4.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,83 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import { useMemiBoardAuth } from 'memi-board'
4
+ import { useMemiBoardComments } from 'memi-board'
5
+ import { useMemiBoardModeration } from 'memi-board'
6
+
7
+ const props = defineProps<{ postId: string }>()
8
+
9
+ const { user, isSignedIn } = useMemiBoardAuth()
10
+ const { addComment } = useMemiBoardComments(props.postId)
11
+ const { checkText } = useMemiBoardModeration()
12
+
13
+ const body = ref('')
14
+ const submitting = ref(false)
15
+ const error = ref('')
16
+
17
+ async function handleSubmit() {
18
+ error.value = ''
19
+ if (!body.value.trim()) return
20
+ if (!user.value) {
21
+ error.value = '로그인이 필요합니다.'
22
+ return
23
+ }
24
+
25
+ submitting.value = true
26
+ try {
27
+ const moderation = await checkText(body.value)
28
+ if (moderation.flagged) {
29
+ error.value = moderation.reason || '작성할 수 없는 내용이 포함되어 있습니다.'
30
+ return
31
+ }
32
+ await addComment({
33
+ body: body.value,
34
+ authorUid: user.value.uid,
35
+ authorName: user.value.displayName,
36
+ authorPhoto: user.value.photoURL,
37
+ })
38
+ body.value = ''
39
+ }
40
+ catch (e) {
41
+ error.value = (e as Error).message
42
+ }
43
+ finally {
44
+ submitting.value = false
45
+ }
46
+ }
47
+ </script>
48
+
49
+ <template>
50
+ <form
51
+ v-if="isSignedIn"
52
+ class="flex flex-col gap-2"
53
+ @submit.prevent="handleSubmit"
54
+ >
55
+ <UTextarea
56
+ v-model="body"
57
+ placeholder="댓글을 입력하세요"
58
+ :rows="2"
59
+ />
60
+ <div class="flex justify-between items-center">
61
+ <p
62
+ v-if="error"
63
+ class="text-xs text-error"
64
+ >
65
+ {{ error }}
66
+ </p>
67
+ <div class="flex-1" />
68
+ <UButton
69
+ type="submit"
70
+ size="sm"
71
+ label="댓글 작성"
72
+ :loading="submitting"
73
+ :disabled="!body.trim()"
74
+ />
75
+ </div>
76
+ </form>
77
+ <p
78
+ v-else
79
+ class="text-sm text-muted"
80
+ >
81
+ 댓글을 작성하려면 로그인이 필요합니다.
82
+ </p>
83
+ </template>
@@ -0,0 +1,64 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import { formatDate } from 'memi-board'
4
+ import { useMemiBoardComments } from 'memi-board'
5
+ import { useMemiBoardAuth } from 'memi-board'
6
+
7
+ const props = defineProps<{ postId: string }>()
8
+
9
+ const { comments, deleteComment } = useMemiBoardComments(props.postId)
10
+ const { canDeleteComment } = useMemiBoardAuth()
11
+
12
+ const deletingId = ref<string | null>(null)
13
+
14
+ async function handleDelete(commentId: string) {
15
+ deletingId.value = commentId
16
+ try {
17
+ await deleteComment(commentId)
18
+ }
19
+ finally {
20
+ deletingId.value = null
21
+ }
22
+ }
23
+ </script>
24
+
25
+ <template>
26
+ <div class="flex flex-col gap-3">
27
+ <p
28
+ v-if="!comments.length"
29
+ class="text-sm text-muted"
30
+ >
31
+ 아직 댓글이 없습니다.
32
+ </p>
33
+
34
+ <div
35
+ v-for="comment in comments"
36
+ :key="comment.id"
37
+ class="flex items-start gap-3"
38
+ >
39
+ <UAvatar
40
+ :src="comment.authorPhoto ?? undefined"
41
+ :alt="comment.authorName ?? '익명'"
42
+ size="sm"
43
+ />
44
+ <div class="flex-1 min-w-0">
45
+ <div class="flex items-center gap-2">
46
+ <span class="text-sm font-medium">{{ comment.authorName ?? '익명' }}</span>
47
+ <span class="text-xs text-muted">{{ formatDate(comment.createdAt) }}</span>
48
+ </div>
49
+ <p class="text-sm whitespace-pre-wrap break-words">
50
+ {{ comment.body }}
51
+ </p>
52
+ </div>
53
+ <UButton
54
+ v-if="canDeleteComment(comment)"
55
+ icon="i-lucide-trash-2"
56
+ size="xs"
57
+ variant="ghost"
58
+ color="error"
59
+ :loading="deletingId === comment.id"
60
+ @click="handleDelete(comment.id!)"
61
+ />
62
+ </div>
63
+ </div>
64
+ </template>
@@ -0,0 +1,156 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, watch } from 'vue'
3
+ import type { PostDetail } from 'memi-board'
4
+ import { formatDate } from 'memi-board'
5
+ import { useMemiBoardPosts } from 'memi-board'
6
+ import { useMemiBoardAuth } from 'memi-board'
7
+ import { useMemiBoardSettings } from 'memi-board'
8
+ import { useMemiBoardComments } from 'memi-board'
9
+ import MemiBoardAttachments from './Attachments.vue'
10
+ import MemiBoardCommentForm from './CommentForm.vue'
11
+ import MemiBoardCommentList from './CommentList.vue'
12
+
13
+ const props = defineProps<{ postId: string }>()
14
+
15
+ const emit = defineEmits<{ deleted: [], edit: [postId: string] }>()
16
+
17
+ const { getPost, deletePost } = useMemiBoardPosts()
18
+ const { canEdit, canDelete } = useMemiBoardAuth()
19
+ const { categoryLabel } = useMemiBoardSettings()
20
+ // post.commentCount는 상세 진입 시점의 스냅샷이라 새 댓글이 즉시 반영되지 않는다 — 실시간 댓글 목록 길이로 표시한다.
21
+ const { comments } = useMemiBoardComments(props.postId)
22
+
23
+ const post = ref<PostDetail | null>(null)
24
+ const loading = ref(true)
25
+ const deleting = ref(false)
26
+ const notFound = ref(false)
27
+
28
+ async function load() {
29
+ loading.value = true
30
+ notFound.value = false
31
+ const result = await getPost(props.postId)
32
+ if (!result) {
33
+ notFound.value = true
34
+ }
35
+ post.value = result
36
+ loading.value = false
37
+ }
38
+
39
+ onMounted(load)
40
+ watch(() => props.postId, load)
41
+
42
+ async function handleDelete() {
43
+ if (!post.value) return
44
+ if (!window.confirm('이 게시글을 삭제하시겠습니까? 되돌릴 수 없습니다.')) return
45
+ deleting.value = true
46
+ try {
47
+ await deletePost(props.postId)
48
+ emit('deleted')
49
+ }
50
+ finally {
51
+ deleting.value = false
52
+ }
53
+ }
54
+ </script>
55
+
56
+ <template>
57
+ <div
58
+ v-if="loading"
59
+ class="flex flex-col gap-3"
60
+ >
61
+ <USkeleton class="h-8 w-2/3" />
62
+ <USkeleton class="h-32 w-full" />
63
+ </div>
64
+
65
+ <p
66
+ v-else-if="notFound"
67
+ class="text-sm text-muted"
68
+ >
69
+ 게시글을 찾을 수 없습니다.
70
+ </p>
71
+
72
+ <div
73
+ v-else-if="post"
74
+ class="flex flex-col gap-6"
75
+ >
76
+ <header class="flex flex-col gap-2">
77
+ <div class="flex items-start justify-between gap-4">
78
+ <div class="flex items-center gap-2 min-w-0">
79
+ <UBadge
80
+ v-if="post.category"
81
+ :label="categoryLabel(post.category)"
82
+ variant="subtle"
83
+ />
84
+ <h1 class="text-2xl font-semibold">
85
+ {{ post.title }}
86
+ </h1>
87
+ </div>
88
+ <div
89
+ v-if="canEdit(post) || canDelete(post)"
90
+ class="flex gap-2 shrink-0"
91
+ >
92
+ <UButton
93
+ v-if="canEdit(post)"
94
+ icon="i-lucide-pencil"
95
+ size="sm"
96
+ variant="outline"
97
+ color="neutral"
98
+ label="수정"
99
+ @click="emit('edit', postId)"
100
+ />
101
+ <UButton
102
+ v-if="canDelete(post)"
103
+ icon="i-lucide-trash-2"
104
+ size="sm"
105
+ variant="outline"
106
+ color="error"
107
+ label="삭제"
108
+ :loading="deleting"
109
+ @click="handleDelete"
110
+ />
111
+ </div>
112
+ </div>
113
+ <div class="flex items-center gap-3 text-sm text-muted">
114
+ <UAvatar
115
+ :src="post.authorPhoto ?? undefined"
116
+ :alt="post.authorName ?? '익명'"
117
+ size="xs"
118
+ />
119
+ <span>{{ post.authorName ?? '익명' }}</span>
120
+ <span>{{ formatDate(post.createdAt) }}</span>
121
+ </div>
122
+ <div
123
+ v-if="post.tags?.length"
124
+ class="flex gap-1"
125
+ >
126
+ <UBadge
127
+ v-for="tag in post.tags"
128
+ :key="tag"
129
+ variant="subtle"
130
+ color="neutral"
131
+ size="sm"
132
+ >
133
+ {{ tag }}
134
+ </UBadge>
135
+ </div>
136
+ </header>
137
+
138
+ <p class="whitespace-pre-wrap break-words leading-relaxed">
139
+ {{ post.content }}
140
+ </p>
141
+
142
+ <MemiBoardAttachments
143
+ v-if="post.attachments?.length"
144
+ :model-value="post.attachments"
145
+ :post-id="postId"
146
+ />
147
+
148
+ <section class="flex flex-col gap-4 border-t border-default pt-4">
149
+ <h2 class="text-sm font-medium text-muted">
150
+ 댓글 {{ comments.length }}
151
+ </h2>
152
+ <MemiBoardCommentForm :post-id="postId" />
153
+ <MemiBoardCommentList :post-id="postId" />
154
+ </section>
155
+ </div>
156
+ </template>
@@ -0,0 +1,168 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted } from 'vue'
3
+ import type { Attachment } from 'memi-board'
4
+ import { useMemiBoardAuth } from 'memi-board'
5
+ import { useMemiBoardPosts } from 'memi-board'
6
+ import { useMemiBoardModeration } from 'memi-board'
7
+ import { useMemiBoardSettings } from 'memi-board'
8
+ import MemiBoardAttachments from './Attachments.vue'
9
+
10
+ const props = defineProps<{
11
+ /** 지정하면 수정 모드, 지정하지 않으면 새 글 작성 모드. */
12
+ postId?: string
13
+ }>()
14
+
15
+ const emit = defineEmits<{ saved: [id: string], cancel: [] }>()
16
+
17
+ const { user } = useMemiBoardAuth()
18
+ const { getPost, createPost, updatePost } = useMemiBoardPosts()
19
+ const { checkText } = useMemiBoardModeration()
20
+ const { categories } = useMemiBoardSettings()
21
+
22
+ const title = ref('')
23
+ const content = ref('')
24
+ const tagsInput = ref('')
25
+ const category = ref<string | undefined>(undefined)
26
+ const attachments = ref<Attachment[]>([])
27
+
28
+ const categoryItems = computed(() => categories.value.map(c => ({ label: c.label, value: c.id })))
29
+
30
+ const loading = ref(!!props.postId)
31
+ const saving = ref(false)
32
+ const error = ref('')
33
+
34
+ /** 새 글은 아직 slug가 없으므로 첨부파일 Storage 경로용 임시 네임스페이스를 쓴다. */
35
+ const attachmentNamespace = ref(props.postId ?? `new-${Date.now()}`)
36
+
37
+ onMounted(async () => {
38
+ if (!props.postId) return
39
+ try {
40
+ const post = await getPost(props.postId)
41
+ if (!post) {
42
+ error.value = '게시글을 찾을 수 없습니다.'
43
+ return
44
+ }
45
+ title.value = post.title
46
+ content.value = post.content
47
+ tagsInput.value = (post.tags ?? []).join(', ')
48
+ category.value = post.category
49
+ attachments.value = post.attachments ?? []
50
+ }
51
+ finally {
52
+ loading.value = false
53
+ }
54
+ })
55
+
56
+ async function handleSubmit() {
57
+ error.value = ''
58
+ if (!title.value.trim() || !content.value.trim()) {
59
+ error.value = '제목과 내용을 입력해 주세요.'
60
+ return
61
+ }
62
+ if (!user.value) {
63
+ error.value = '로그인이 필요합니다.'
64
+ return
65
+ }
66
+
67
+ saving.value = true
68
+ try {
69
+ const moderation = await checkText(`${title.value}\n${content.value}`)
70
+ if (moderation.flagged) {
71
+ error.value = moderation.reason || '게시할 수 없는 내용이 포함되어 있습니다.'
72
+ return
73
+ }
74
+
75
+ const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
76
+
77
+ if (props.postId) {
78
+ await updatePost(props.postId, { title: title.value, content: content.value, tags, category: category.value, attachments: attachments.value })
79
+ emit('saved', props.postId)
80
+ }
81
+ else {
82
+ const id = await createPost({
83
+ title: title.value,
84
+ content: content.value,
85
+ tags,
86
+ category: category.value,
87
+ attachments: attachments.value,
88
+ authorUid: user.value.uid,
89
+ authorName: user.value.displayName,
90
+ authorPhoto: user.value.photoURL,
91
+ })
92
+ emit('saved', id)
93
+ }
94
+ }
95
+ catch (e) {
96
+ error.value = (e as Error).message
97
+ }
98
+ finally {
99
+ saving.value = false
100
+ }
101
+ }
102
+ </script>
103
+
104
+ <template>
105
+ <div
106
+ v-if="loading"
107
+ class="flex flex-col gap-3"
108
+ >
109
+ <USkeleton class="h-10 w-full" />
110
+ <USkeleton class="h-40 w-full" />
111
+ </div>
112
+
113
+ <form
114
+ v-else
115
+ class="flex flex-col gap-4"
116
+ @submit.prevent="handleSubmit"
117
+ >
118
+ <USelect
119
+ v-model="category"
120
+ :items="categoryItems"
121
+ placeholder="카테고리 선택"
122
+ class="w-40"
123
+ />
124
+ <UInput
125
+ v-model="title"
126
+ placeholder="제목"
127
+ size="lg"
128
+ required
129
+ />
130
+ <UTextarea
131
+ v-model="content"
132
+ placeholder="내용을 입력하세요"
133
+ :rows="12"
134
+ required
135
+ />
136
+ <UInput
137
+ v-model="tagsInput"
138
+ placeholder="태그 (쉼표로 구분)"
139
+ />
140
+
141
+ <MemiBoardAttachments
142
+ v-model="attachments"
143
+ :post-id="attachmentNamespace"
144
+ editable
145
+ />
146
+
147
+ <p
148
+ v-if="error"
149
+ class="text-sm text-error"
150
+ >
151
+ {{ error }}
152
+ </p>
153
+
154
+ <div class="flex gap-2">
155
+ <UButton
156
+ type="submit"
157
+ :loading="saving"
158
+ :label="postId ? '수정 완료' : '게시하기'"
159
+ />
160
+ <UButton
161
+ variant="ghost"
162
+ color="neutral"
163
+ label="취소"
164
+ @click="emit('cancel')"
165
+ />
166
+ </div>
167
+ </form>
168
+ </template>
@@ -0,0 +1,121 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted } from 'vue'
3
+ import { RouterLink } from 'vue-router'
4
+ import type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'
5
+ import { useMemiBoardPosts } from 'memi-board'
6
+ import { useMemiBoardSettings } from 'memi-board'
7
+ import type { PostModel } from 'memi-board'
8
+ import { formatDate } from 'memi-board'
9
+
10
+ const props = withDefaults(defineProps<{
11
+ pageSize?: number
12
+ /** 지정하면 각 글이 `${linkBase}/${id}`로 이동하는 링크가 된다. 지정하지 않으면 select 이벤트만 emit. */
13
+ linkBase?: string
14
+ }>(), {
15
+ pageSize: 20,
16
+ })
17
+
18
+ const emit = defineEmits<{ select: [post: PostModel] }>()
19
+
20
+ const { getPosts } = useMemiBoardPosts()
21
+ const { categoryLabel } = useMemiBoardSettings()
22
+
23
+ const posts = ref<PostModel[]>([])
24
+ const cursor = ref<QueryDocumentSnapshot<DocumentData> | undefined>(undefined)
25
+ const hasMore = ref(false)
26
+ const loading = ref(false)
27
+ const initialLoading = ref(true)
28
+
29
+ async function loadMore() {
30
+ loading.value = true
31
+ try {
32
+ const result = await getPosts({ pageSize: props.pageSize, cursor: cursor.value })
33
+ posts.value.push(...result.posts)
34
+ cursor.value = result.cursor
35
+ hasMore.value = result.hasMore
36
+ }
37
+ finally {
38
+ loading.value = false
39
+ initialLoading.value = false
40
+ }
41
+ }
42
+
43
+ onMounted(loadMore)
44
+
45
+ function handleClick(post: PostModel) {
46
+ if (!props.linkBase) emit('select', post)
47
+ }
48
+ </script>
49
+
50
+ <template>
51
+ <div class="flex flex-col gap-2">
52
+ <template v-if="initialLoading">
53
+ <USkeleton
54
+ v-for="i in 3"
55
+ :key="i"
56
+ class="h-20 w-full"
57
+ />
58
+ </template>
59
+
60
+ <p
61
+ v-else-if="!posts.length"
62
+ class="text-sm text-muted text-center py-8"
63
+ >
64
+ 아직 게시글이 없습니다.
65
+ </p>
66
+
67
+ <component
68
+ :is="linkBase ? RouterLink : 'button'"
69
+ v-for="post in posts"
70
+ :key="post.id"
71
+ :to="linkBase ? `${linkBase}/${post.id}` : undefined"
72
+ class="text-left"
73
+ @click="handleClick(post)"
74
+ >
75
+ <UCard class="hover:bg-elevated/50 transition-colors">
76
+ <div class="flex items-center justify-between gap-4">
77
+ <div class="flex items-center gap-2 min-w-0">
78
+ <UBadge
79
+ v-if="post.category"
80
+ :label="categoryLabel(post.category)"
81
+ variant="subtle"
82
+ size="sm"
83
+ />
84
+ <h3 class="font-medium truncate">
85
+ {{ post.title }}
86
+ </h3>
87
+ </div>
88
+ <span class="text-xs text-muted shrink-0">{{ formatDate(post.createdAt) }}</span>
89
+ </div>
90
+ <div class="flex items-center gap-3 text-xs text-muted mt-2">
91
+ <span>{{ post.authorName ?? '익명' }}</span>
92
+ <span class="flex items-center gap-1">
93
+ <UIcon
94
+ name="i-lucide-message-circle"
95
+ class="size-3"
96
+ />{{ post.commentCount }}
97
+ </span>
98
+ <span
99
+ v-if="post.attachments?.length"
100
+ class="flex items-center gap-1"
101
+ >
102
+ <UIcon
103
+ name="i-lucide-paperclip"
104
+ class="size-3"
105
+ />{{ post.attachments.length }}
106
+ </span>
107
+ </div>
108
+ </UCard>
109
+ </component>
110
+
111
+ <UButton
112
+ v-if="hasMore"
113
+ variant="outline"
114
+ color="neutral"
115
+ label="더 보기"
116
+ block
117
+ :loading="loading"
118
+ @click="loadMore"
119
+ />
120
+ </div>
121
+ </template>